Skip to main content

metaxy_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.base_name() {
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, preserving generics
90        other => {
91            if ty.generics.is_empty() {
92                other.to_string()
93            } else {
94                let params: Vec<String> = ty.generics.iter().map(rust_type_to_ts).collect();
95                format!("{other}<{}>", params.join(", "))
96            }
97        }
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        emit!(out, "{indent}/** {doc} */");
105    } else {
106        emit!(out, "{indent}/**");
107        for line in doc.lines() {
108            emit!(out, "{indent} * {line}");
109        }
110        emit!(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    let mut result = segments.next().unwrap_or_default().to_lowercase();
118    for segment in segments {
119        let mut chars = segment.chars();
120        if let Some(first) = chars.next() {
121            result.extend(first.to_uppercase());
122            result.push_str(&chars.as_str().to_lowercase());
123        }
124    }
125    result
126}
127
128/// Transforms a field name according to the naming strategy.
129fn transform_field_name(name: &str, naming: FieldNaming) -> String {
130    match naming {
131        FieldNaming::Preserve => name.to_string(),
132        FieldNaming::CamelCase => to_camel_case(name),
133    }
134}
135
136/// Resolves the final output name for a struct/variant field.
137///
138/// Priority: field `rename` > container `rename_all` > config `field_naming` > original name.
139fn resolve_field_name(
140    field: &FieldDef,
141    container_rename_all: Option<RenameRule>,
142    config_naming: FieldNaming,
143) -> String {
144    if let Some(rename) = &field.rename {
145        return rename.clone();
146    }
147    if let Some(rule) = container_rename_all {
148        return rule.apply(&field.name);
149    }
150    transform_field_name(&field.name, config_naming)
151}
152
153/// Resolves the final output name for an enum variant.
154///
155/// Priority: variant `rename` > container `rename_all` > original name.
156fn resolve_variant_name(variant: &EnumVariant, container_rename_all: Option<RenameRule>) -> String {
157    if let Some(rename) = &variant.rename {
158        return rename.clone();
159    }
160    if let Some(rule) = container_rename_all {
161        return rule.apply(&variant.name);
162    }
163    variant.name.clone()
164}
165
166/// Returns the inner type of `Option<T>`, or `None` if not an Option.
167fn option_inner_type(ty: &RustType) -> Option<&RustType> {
168    if ty.name == "Option" {
169        ty.generics.first()
170    } else {
171        None
172    }
173}
174
175/// Renders a single struct/variant field as `name: T` or `name?: T | null`.
176///
177/// When a field has `#[serde(default)]` and is `Option<T>`, it becomes optional
178/// (`name?: T | null`). Otherwise it renders as a required field (`name: T`).
179fn render_field_str(
180    field: &FieldDef,
181    container_rename_all: Option<RenameRule>,
182    config_naming: FieldNaming,
183) -> String {
184    let name = resolve_field_name(field, container_rename_all, config_naming);
185    if field.has_default
186        && let Some(inner) = option_inner_type(&field.ty)
187    {
188        format!("{}?: {} | null", name, rust_type_to_ts(inner))
189    } else {
190        format!("{}: {}", name, rust_type_to_ts(&field.ty))
191    }
192}
193
194/// Splits fields into regular fields and flattened type names.
195///
196/// - Skipped fields are omitted entirely.
197/// - Flattened fields contribute their type name to the intersection list.
198/// - Regular fields are rendered as `name: T` strings.
199fn render_struct_body(
200    fields: &[FieldDef],
201    rename_all: Option<RenameRule>,
202    field_naming: FieldNaming,
203) -> (Vec<String>, Vec<String>) {
204    let mut regular = Vec::new();
205    let mut flattened = Vec::new();
206    for f in fields {
207        if f.skip {
208            continue;
209        }
210        if f.flatten {
211            flattened.push(rust_type_to_ts(&f.ty));
212        } else {
213            regular.push(render_field_str(f, rename_all, field_naming));
214        }
215    }
216    (regular, flattened)
217}
218
219/// Builds an inline `{ field; field }` object string, optionally intersected with
220/// flattened types. Returns the combined expression.
221fn build_object_with_flatten(regular: &[String], flattened: &[String]) -> String {
222    let mut parts = Vec::new();
223    if !regular.is_empty() {
224        parts.push(format!("{{ {} }}", regular.join("; ")));
225    }
226    parts.extend(flattened.iter().cloned());
227    parts.join(" & ")
228}
229
230/// Generates a TypeScript interface or type alias from a struct definition.
231///
232/// - Named structs → `export interface Name { ... }`
233/// - Named structs with flatten → `export type Name = { ... } & Flattened;`
234/// - Single-field tuple structs (newtypes) → `export type Name = inner;`
235///   (with optional branded type when `branded_newtypes` is enabled)
236/// - Multi-field tuple structs → `export type Name = [A, B, ...];`
237fn generate_interface(
238    s: &StructDef,
239    preserve_docs: bool,
240    field_naming: FieldNaming,
241    branded_newtypes: bool,
242    out: &mut String,
243) {
244    if preserve_docs && let Some(doc) = &s.docs {
245        emit_jsdoc(doc, "", out);
246    }
247    let generic_params = format_generic_params(&s.generics);
248
249    // Tuple struct handling
250    if !s.tuple_fields.is_empty() {
251        if s.tuple_fields.len() == 1 {
252            // Newtype: single-field tuple struct
253            let inner = rust_type_to_ts(&s.tuple_fields[0]);
254            if branded_newtypes {
255                emit!(
256                    out,
257                    "export type {}{generic_params} = {inner} & {{ readonly __brand: \"{}\" }};",
258                    s.name,
259                    s.name
260                );
261            } else {
262                emit!(out, "export type {}{generic_params} = {inner};", s.name);
263            }
264        } else {
265            // Multi-field tuple struct → TS tuple
266            let elems: Vec<String> = s.tuple_fields.iter().map(rust_type_to_ts).collect();
267            emit!(
268                out,
269                "export type {}{generic_params} = [{}];",
270                s.name,
271                elems.join(", ")
272            );
273        }
274        return;
275    }
276
277    let (regular, flattened) = render_struct_body(&s.fields, s.rename_all, field_naming);
278
279    if flattened.is_empty() {
280        // No flatten → standard interface
281        emit!(out, "export interface {}{generic_params} {{", s.name);
282        for r in &regular {
283            emit!(out, "  {r};");
284        }
285        emit!(out, "}}");
286    } else {
287        // Has flatten → export type with intersection
288        let body = build_object_with_flatten(&regular, &flattened);
289        emit!(out, "export type {}{generic_params} = {body};", s.name);
290    }
291}
292
293/// Generates a TypeScript type from an enum definition.
294///
295/// Dispatches to the appropriate strategy based on `e.tagging`:
296/// - `External` (default): serde's externally tagged representation
297/// - `Internal { tag }`: internally tagged (`#[serde(tag = "...")]`)
298/// - `Adjacent { tag, content }`: adjacently tagged (`#[serde(tag = "...", content = "...")]`)
299/// - `Untagged`: no tag wrapper (`#[serde(untagged)]`)
300fn generate_enum_type(
301    e: &EnumDef,
302    preserve_docs: bool,
303    field_naming: FieldNaming,
304    out: &mut String,
305) {
306    if preserve_docs && let Some(doc) = &e.docs {
307        emit_jsdoc(doc, "", out);
308    }
309
310    match &e.tagging {
311        EnumTagging::External => generate_enum_external(e, field_naming, out),
312        EnumTagging::Internal { tag } => generate_enum_internal(e, tag, field_naming, out),
313        EnumTagging::Adjacent { tag, content } => {
314            generate_enum_adjacent(e, tag, content, field_naming, out);
315        }
316        EnumTagging::Untagged => generate_enum_untagged(e, field_naming, out),
317    }
318}
319
320/// Externally tagged (serde default): `{ "Variant": data }` or string literal for unit variants.
321fn generate_enum_external(e: &EnumDef, field_naming: FieldNaming, out: &mut String) {
322    let generic_params = format_generic_params(&e.generics);
323    let all_unit = e
324        .variants
325        .iter()
326        .all(|v| matches!(v.kind, VariantKind::Unit));
327
328    if all_unit {
329        let variants: Vec<String> = e
330            .variants
331            .iter()
332            .map(|v| {
333                let name = resolve_variant_name(v, e.rename_all);
334                format!("\"{name}\"")
335            })
336            .collect();
337        if variants.is_empty() {
338            emit!(out, "export type {}{generic_params} = never;", e.name);
339        } else {
340            emit!(
341                out,
342                "export type {}{generic_params} = {};",
343                e.name,
344                variants.join(" | ")
345            );
346        }
347    } else {
348        let mut variant_types: Vec<String> = Vec::new();
349
350        for v in &e.variants {
351            let variant_name = resolve_variant_name(v, e.rename_all);
352            match &v.kind {
353                VariantKind::Unit => {
354                    variant_types.push(format!("\"{variant_name}\""));
355                }
356                VariantKind::Tuple(types) => {
357                    let inner = if types.len() == 1 {
358                        rust_type_to_ts(&types[0])
359                    } else {
360                        let elems: Vec<String> = types.iter().map(rust_type_to_ts).collect();
361                        format!("[{}]", elems.join(", "))
362                    };
363                    variant_types.push(format!("{{ {variant_name}: {inner} }}"));
364                }
365                VariantKind::Struct(fields) => {
366                    let (regular, flattened) =
367                        render_struct_body(fields, e.rename_all, field_naming);
368                    let inner = build_object_with_flatten(&regular, &flattened);
369                    variant_types.push(format!("{{ {variant_name}: {inner} }}"));
370                }
371            }
372        }
373
374        emit!(
375            out,
376            "export type {}{generic_params} = {};",
377            e.name,
378            variant_types.join(" | ")
379        );
380    }
381}
382
383/// Internally tagged: `{ "tag": "Variant", ...fields }`.
384///
385/// - Unit → `{ tag: "Name" }`
386/// - Struct → `{ tag: "Name"; field: T; ... }`
387/// - Tuple(1) → `{ tag: "Name" } & InnerType` (newtype wrapping struct)
388/// - Tuple(n>1) → skipped (serde rejects multi-field tuples in internally tagged)
389fn generate_enum_internal(e: &EnumDef, tag: &str, field_naming: FieldNaming, out: &mut String) {
390    let generic_params = format_generic_params(&e.generics);
391    if e.variants.is_empty() {
392        emit!(out, "export type {}{generic_params} = never;", e.name);
393        return;
394    }
395
396    let mut variant_types: Vec<String> = Vec::new();
397
398    for v in &e.variants {
399        let variant_name = resolve_variant_name(v, e.rename_all);
400        match &v.kind {
401            VariantKind::Unit => {
402                variant_types.push(format!("{{ {tag}: \"{variant_name}\" }}"));
403            }
404            VariantKind::Struct(fields) => {
405                let (regular, flattened) = render_struct_body(fields, e.rename_all, field_naming);
406                let mut parts = vec![format!("{tag}: \"{variant_name}\"")];
407                parts.extend(regular);
408                let obj = format!("{{ {} }}", parts.join("; "));
409                if flattened.is_empty() {
410                    variant_types.push(obj);
411                } else {
412                    let all: Vec<String> = std::iter::once(obj).chain(flattened).collect();
413                    variant_types.push(all.join(" & "));
414                }
415            }
416            VariantKind::Tuple(types) => {
417                if types.len() == 1 {
418                    let inner = rust_type_to_ts(&types[0]);
419                    variant_types.push(format!("{{ {tag}: \"{variant_name}\" }} & {inner}"));
420                }
421                // Multi-field tuples are rejected by serde for internal tagging — skip
422            }
423        }
424    }
425
426    if variant_types.is_empty() {
427        emit!(out, "export type {}{generic_params} = never;", e.name);
428    } else {
429        emit!(
430            out,
431            "export type {}{generic_params} = {};",
432            e.name,
433            variant_types.join(" | ")
434        );
435    }
436}
437
438/// Adjacently tagged: `{ "tag": "Variant", "content": data }`.
439///
440/// - Unit → `{ tag: "Name" }` (no content key)
441/// - Tuple(1) → `{ tag: "Name"; content: T }`
442/// - Tuple(n) → `{ tag: "Name"; content: [A, B, ...] }`
443/// - Struct → `{ tag: "Name"; content: { field: T; ... } }`
444fn generate_enum_adjacent(
445    e: &EnumDef,
446    tag: &str,
447    content: &str,
448    field_naming: FieldNaming,
449    out: &mut String,
450) {
451    let generic_params = format_generic_params(&e.generics);
452    if e.variants.is_empty() {
453        emit!(out, "export type {}{generic_params} = never;", e.name);
454        return;
455    }
456
457    let mut variant_types: Vec<String> = Vec::new();
458
459    for v in &e.variants {
460        let variant_name = resolve_variant_name(v, e.rename_all);
461        match &v.kind {
462            VariantKind::Unit => {
463                variant_types.push(format!("{{ {tag}: \"{variant_name}\" }}"));
464            }
465            VariantKind::Tuple(types) => {
466                let inner = if types.len() == 1 {
467                    rust_type_to_ts(&types[0])
468                } else {
469                    let elems: Vec<String> = types.iter().map(rust_type_to_ts).collect();
470                    format!("[{}]", elems.join(", "))
471                };
472                variant_types.push(format!(
473                    "{{ {tag}: \"{variant_name}\"; {content}: {inner} }}"
474                ));
475            }
476            VariantKind::Struct(fields) => {
477                let (regular, flattened) = render_struct_body(fields, e.rename_all, field_naming);
478                let inner = build_object_with_flatten(&regular, &flattened);
479                variant_types.push(format!(
480                    "{{ {tag}: \"{variant_name}\"; {content}: {inner} }}"
481                ));
482            }
483        }
484    }
485
486    emit!(
487        out,
488        "export type {}{generic_params} = {};",
489        e.name,
490        variant_types.join(" | ")
491    );
492}
493
494/// Untagged: no wrapper, just the data.
495///
496/// - Unit → `null`
497/// - Tuple(1) → `T`
498/// - Tuple(n) → `[A, B, ...]`
499/// - Struct → `{ field: T; ... }`
500/// - Empty enum → `never`
501fn generate_enum_untagged(e: &EnumDef, field_naming: FieldNaming, out: &mut String) {
502    let generic_params = format_generic_params(&e.generics);
503    if e.variants.is_empty() {
504        emit!(out, "export type {}{generic_params} = never;", e.name);
505        return;
506    }
507
508    let mut variant_types: Vec<String> = Vec::new();
509
510    for v in &e.variants {
511        match &v.kind {
512            VariantKind::Unit => {
513                variant_types.push("null".to_string());
514            }
515            VariantKind::Tuple(types) => {
516                if types.len() == 1 {
517                    variant_types.push(rust_type_to_ts(&types[0]));
518                } else {
519                    let elems: Vec<String> = types.iter().map(rust_type_to_ts).collect();
520                    variant_types.push(format!("[{}]", elems.join(", ")));
521                }
522            }
523            VariantKind::Struct(fields) => {
524                let (regular, flattened) = render_struct_body(fields, e.rename_all, field_naming);
525                let inner = build_object_with_flatten(&regular, &flattened);
526                variant_types.push(inner);
527            }
528        }
529    }
530
531    emit!(
532        out,
533        "export type {}{generic_params} = {};",
534        e.name,
535        variant_types.join(" | ")
536    );
537}
538
539/// Formats generic type parameters for TypeScript output (e.g. `<T>`, `<A, B>`).
540///
541/// Returns an empty string when there are no generic parameters.
542fn format_generic_params(generics: &[String]) -> String {
543    if generics.is_empty() {
544        String::new()
545    } else {
546        format!("<{}>", generics.join(", "))
547    }
548}
549
550/// Generates the `Procedures` type that maps procedure names to their input/output types,
551/// grouped by kind (queries / mutations).
552fn generate_procedures_type(procedures: &[Procedure], preserve_docs: bool, out: &mut String) {
553    let (queries, mutations): (Vec<_>, Vec<_>) = procedures
554        .iter()
555        .partition(|p| p.kind == ProcedureKind::Query);
556
557    emit!(out, "export type Procedures = {{");
558
559    // Queries
560    emit!(out, "  queries: {{");
561    for proc in &queries {
562        if preserve_docs && let Some(doc) = &proc.docs {
563            emit_jsdoc(doc, "    ", out);
564        }
565        let input = proc
566            .input
567            .as_ref()
568            .map(rust_type_to_ts)
569            .unwrap_or_else(|| "void".to_string());
570        let output = proc
571            .output
572            .as_ref()
573            .map(rust_type_to_ts)
574            .unwrap_or_else(|| "void".to_string());
575        emit!(
576            out,
577            "    {}: {{ input: {input}; output: {output} }};",
578            proc.name
579        );
580    }
581    emit!(out, "  }};");
582
583    // Mutations
584    emit!(out, "  mutations: {{");
585    for proc in &mutations {
586        if preserve_docs && let Some(doc) = &proc.docs {
587            emit_jsdoc(doc, "    ", out);
588        }
589        let input = proc
590            .input
591            .as_ref()
592            .map(rust_type_to_ts)
593            .unwrap_or_else(|| "void".to_string());
594        let output = proc
595            .output
596            .as_ref()
597            .map(rust_type_to_ts)
598            .unwrap_or_else(|| "void".to_string());
599        emit!(
600            out,
601            "    {}: {{ input: {input}; output: {output} }};",
602            proc.name
603        );
604    }
605    emit!(out, "  }};");
606
607    emit!(out, "}};");
608}
609
610/// Generates the complete `rpc-types.ts` file content from a manifest.
611///
612/// The output includes:
613/// 1. Auto-generation header
614/// 2. TypeScript interfaces for all referenced structs
615/// 3. The `Procedures` type mapping
616pub fn generate_types_file(
617    manifest: &Manifest,
618    preserve_docs: bool,
619    field_naming: FieldNaming,
620    branded_newtypes: bool,
621) -> String {
622    let mut out = String::with_capacity(1024);
623
624    // Header
625    out.push_str(GENERATED_HEADER);
626    out.push('\n');
627
628    // Emit all structs discovered in the scanned files.
629    for s in &manifest.structs {
630        generate_interface(s, preserve_docs, field_naming, branded_newtypes, &mut out);
631        out.push('\n');
632    }
633
634    // Emit all enums discovered in the scanned files.
635    for e in &manifest.enums {
636        generate_enum_type(e, preserve_docs, field_naming, &mut out);
637        out.push('\n');
638    }
639
640    // Generate the Procedures type
641    generate_procedures_type(&manifest.procedures, preserve_docs, &mut out);
642
643    out
644}