Skip to main content

vox_codegen/targets/typescript/
types.rs

1//! TypeScript type generation and collection.
2//!
3//! This module handles:
4//! - Collecting named types (structs and enums) from service definitions
5//! - Generating TypeScript type definitions (interfaces, type unions)
6//! - Converting Rust types to TypeScript type strings
7
8use std::collections::HashSet;
9
10use facet_core::{ScalarType, Shape};
11use vox_types::{
12    EnumInfo, ServiceDescriptor, ShapeKind, StructInfo, VariantKind, classify_shape,
13    classify_variant, is_bytes,
14};
15
16fn transparent_named_alias(shape: &'static Shape) -> Option<(&'static str, &'static Shape)> {
17    if !shape.is_transparent() {
18        return None;
19    }
20    let name = extract_type_name(shape.type_identifier)?;
21    let inner = shape.inner?;
22    Some((name, inner))
23}
24
25fn extract_type_name(type_identifier: &'static str) -> Option<&'static str> {
26    if type_identifier.is_empty()
27        || type_identifier.starts_with('(')
28        || type_identifier.starts_with('[')
29    {
30        return None;
31    }
32    Some(type_identifier)
33}
34
35/// Generate TypeScript field access expression.
36/// Uses bracket notation for numeric field names (tuple fields), dot notation otherwise.
37pub fn ts_field_access(expr: &str, field_name: &str) -> String {
38    if field_name
39        .chars()
40        .next()
41        .is_some_and(|c| c.is_ascii_digit())
42    {
43        format!("{expr}[{field_name}]")
44    } else {
45        format!("{expr}.{field_name}")
46    }
47}
48
49/// Collect all named types (structs and enums with a name) from a service.
50/// Returns a vector of (name, Shape) pairs in dependency order.
51pub fn collect_named_types(service: &ServiceDescriptor) -> Vec<(String, &'static Shape)> {
52    let mut seen = HashSet::new();
53    let mut types = Vec::new();
54
55    fn visit(
56        shape: &'static Shape,
57        seen: &mut HashSet<String>,
58        types: &mut Vec<(String, &'static Shape)>,
59    ) {
60        if let Some((name, inner)) = transparent_named_alias(shape) {
61            if !seen.contains(name) {
62                seen.insert(name.to_string());
63                visit(inner, seen, types);
64                types.push((name.to_string(), shape));
65            }
66            return;
67        }
68
69        match classify_shape(shape) {
70            ShapeKind::Struct(StructInfo {
71                name: Some(name),
72                fields,
73                ..
74            }) if seen.insert(name.to_string()) => {
75                // Visit nested types first (dependencies before dependents)
76                for field in fields {
77                    visit(field.shape(), seen, types);
78                }
79                types.push((name.to_string(), shape));
80            }
81            ShapeKind::Enum(EnumInfo {
82                name: Some(name),
83                variants,
84            }) if seen.insert(name.to_string()) => {
85                // Visit nested types in variants
86                for variant in variants {
87                    match classify_variant(variant) {
88                        VariantKind::Newtype { inner } => visit(inner, seen, types),
89                        VariantKind::Struct { fields } | VariantKind::Tuple { fields } => {
90                            for field in fields {
91                                visit(field.shape(), seen, types);
92                            }
93                        }
94                        VariantKind::Unit => {}
95                    }
96                }
97                types.push((name.to_string(), shape));
98            }
99            ShapeKind::List { element } => visit(element, seen, types),
100            ShapeKind::Option { inner } => visit(inner, seen, types),
101            ShapeKind::Array { element, .. } => visit(element, seen, types),
102            ShapeKind::Map { key, value } => {
103                visit(key, seen, types);
104                visit(value, seen, types);
105            }
106            ShapeKind::Set { element } => visit(element, seen, types),
107            ShapeKind::Tuple { elements } => {
108                for param in elements {
109                    visit(param.shape, seen, types);
110                }
111            }
112            ShapeKind::Tx { inner } | ShapeKind::Rx { inner } => visit(inner, seen, types),
113            ShapeKind::Pointer { pointee } => visit(pointee, seen, types),
114            ShapeKind::Result { ok, err } => {
115                visit(ok, seen, types);
116                visit(err, seen, types);
117            }
118            // Scalars, slices, opaque - no named types to collect
119            _ => {}
120        }
121    }
122
123    for method in service.methods {
124        for arg in method.args {
125            visit(arg.shape, &mut seen, &mut types);
126        }
127        visit(method.return_shape, &mut seen, &mut types);
128    }
129
130    types
131}
132
133/// Generate TypeScript type definitions for all named types.
134pub fn generate_named_types(named_types: &[(String, &'static Shape)]) -> String {
135    let mut out = String::new();
136
137    if named_types.is_empty() {
138        return out;
139    }
140
141    out.push_str("// Named type definitions\n");
142
143    for (name, shape) in named_types {
144        if let Some((_, inner)) = transparent_named_alias(shape) {
145            out.push_str(&format!(
146                "export type {name} = {};\n\n",
147                ts_type_base_named(inner)
148            ));
149            continue;
150        }
151
152        match classify_shape(shape) {
153            ShapeKind::Struct(StructInfo { fields, .. }) => {
154                out.push_str(&format!("export interface {} {{\n", name));
155                for field in fields {
156                    out.push_str(&format!(
157                        "  {}: {};\n",
158                        field.name,
159                        ts_type_base_named(field.shape())
160                    ));
161                }
162                out.push_str("}\n\n");
163            }
164            ShapeKind::Enum(EnumInfo { variants, .. }) => {
165                out.push_str(&format!("export type {} =\n", name));
166                for (i, variant) in variants.iter().enumerate() {
167                    let variant_type = match classify_variant(variant) {
168                        VariantKind::Unit => format!("{{ tag: '{}' }}", variant.name),
169                        VariantKind::Newtype { inner } => {
170                            format!(
171                                "{{ tag: '{}'; value: {} }}",
172                                variant.name,
173                                ts_type_base_named(inner)
174                            )
175                        }
176                        VariantKind::Tuple { fields } | VariantKind::Struct { fields } => {
177                            let field_strs = fields
178                                .iter()
179                                .map(|f| format!("{}: {}", f.name, ts_type_base_named(f.shape())))
180                                .collect::<Vec<_>>()
181                                .join("; ");
182                            format!("{{ tag: '{}'; {} }}", variant.name, field_strs)
183                        }
184                    };
185                    let sep = if i < variants.len() - 1 { "" } else { ";" };
186                    out.push_str(&format!("  | {}{}\n", variant_type, sep));
187                }
188                out.push('\n');
189            }
190            _ => {}
191        }
192    }
193
194    out
195}
196
197/// Convert Shape to TypeScript type string, using named types when available.
198/// This handles container types recursively, using named types at every level.
199pub fn ts_type_base_named(shape: &'static Shape) -> String {
200    if let Some((name, _)) = transparent_named_alias(shape) {
201        return name.to_string();
202    }
203
204    match classify_shape(shape) {
205        // Named types - use the name directly
206        ShapeKind::Struct(StructInfo {
207            name: Some(name), ..
208        }) => name.to_string(),
209        ShapeKind::Enum(EnumInfo {
210            name: Some(name), ..
211        }) => name.to_string(),
212
213        // Container types - recurse with ts_type_base_named
214        ShapeKind::List { element } => {
215            // Check for bytes first
216            if is_bytes(shape) {
217                return "Uint8Array".into();
218            }
219            // Wrap in parens if inner is an anonymous enum to avoid precedence issues
220            if matches!(
221                classify_shape(element),
222                ShapeKind::Enum(EnumInfo { name: None, .. })
223            ) {
224                format!("({})[]", ts_type_base_named(element))
225            } else {
226                format!("{}[]", ts_type_base_named(element))
227            }
228        }
229        ShapeKind::Option { inner } => format!("{} | null", ts_type_base_named(inner)),
230        ShapeKind::Array { element, len } => format!("[{}; {}]", ts_type_base_named(element), len),
231        ShapeKind::Map { key, value } => {
232            format!(
233                "Map<{}, {}>",
234                ts_type_base_named(key),
235                ts_type_base_named(value)
236            )
237        }
238        ShapeKind::Set { element } => format!("Set<{}>", ts_type_base_named(element)),
239        ShapeKind::Tuple { elements } => {
240            let inner = elements
241                .iter()
242                .map(|p| ts_type_base_named(p.shape))
243                .collect::<Vec<_>>()
244                .join(", ");
245            format!("[{inner}]")
246        }
247        ShapeKind::Tx { inner } => format!("Tx<{}>", ts_type_base_named(inner)),
248        ShapeKind::Rx { inner } => format!("Rx<{}>", ts_type_base_named(inner)),
249
250        // Anonymous structs - inline as object type
251        ShapeKind::Struct(StructInfo {
252            name: None, fields, ..
253        }) => {
254            let inner = fields
255                .iter()
256                .map(|f| format!("{}: {}", f.name, ts_type_base_named(f.shape())))
257                .collect::<Vec<_>>()
258                .join("; ");
259            format!("{{ {inner} }}")
260        }
261
262        // Anonymous enums - inline as union type
263        ShapeKind::Enum(EnumInfo {
264            name: None,
265            variants,
266        }) => variants
267            .iter()
268            .map(|v| match classify_variant(v) {
269                VariantKind::Unit => format!("{{ tag: '{}' }}", v.name),
270                VariantKind::Newtype { inner } => {
271                    format!(
272                        "{{ tag: '{}'; value: {} }}",
273                        v.name,
274                        ts_type_base_named(inner)
275                    )
276                }
277                VariantKind::Tuple { fields } | VariantKind::Struct { fields } => {
278                    let field_strs = fields
279                        .iter()
280                        .map(|f| format!("{}: {}", f.name, ts_type_base_named(f.shape())))
281                        .collect::<Vec<_>>()
282                        .join("; ");
283                    format!("{{ tag: '{}'; {} }}", v.name, field_strs)
284                }
285            })
286            .collect::<Vec<_>>()
287            .join(" | "),
288
289        // Scalars and other types
290        ShapeKind::Scalar(scalar) => ts_scalar_type(scalar),
291        ShapeKind::Slice { element } => format!("{}[]", ts_type_base_named(element)),
292        ShapeKind::Pointer { pointee } => ts_type_base_named(pointee),
293        ShapeKind::Result { ok, err } => {
294            format!(
295                "{{ ok: true; value: {} }} | {{ ok: false; error: {} }}",
296                ts_type_base_named(ok),
297                ts_type_base_named(err)
298            )
299        }
300        ShapeKind::TupleStruct { fields } => {
301            let inner = fields
302                .iter()
303                .map(|f| ts_type_base_named(f.shape()))
304                .collect::<Vec<_>>()
305                .join(", ");
306            format!("[{inner}]")
307        }
308        ShapeKind::Opaque => "unknown".into(),
309    }
310}
311
312/// Convert ScalarType to TypeScript type string.
313pub fn ts_scalar_type(scalar: ScalarType) -> String {
314    match scalar {
315        ScalarType::Bool => "boolean".into(),
316        ScalarType::U8
317        | ScalarType::U16
318        | ScalarType::U32
319        | ScalarType::I8
320        | ScalarType::I16
321        | ScalarType::I32
322        | ScalarType::F32
323        | ScalarType::F64 => "number".into(),
324        ScalarType::U64
325        | ScalarType::U128
326        | ScalarType::I64
327        | ScalarType::I128
328        | ScalarType::USize
329        | ScalarType::ISize => "bigint".into(),
330        ScalarType::Char | ScalarType::Str | ScalarType::String | ScalarType::CowStr => {
331            "string".into()
332        }
333        ScalarType::Unit => "void".into(),
334        _ => "unknown".into(),
335    }
336}
337
338/// Convert Shape to TypeScript type string for client arguments.
339/// Schema is from server's perspective - no inversion needed.
340/// Client passes the same types that server receives.
341pub fn ts_type_client_arg(shape: &'static Shape) -> String {
342    match classify_shape(shape) {
343        ShapeKind::Tx { inner } => format!("Tx<{}>", ts_type_client_arg(inner)),
344        ShapeKind::Rx { inner } => format!("Rx<{}>", ts_type_client_arg(inner)),
345        _ => ts_type_base_named(shape),
346    }
347}
348
349/// Convert Shape to TypeScript type string for client returns.
350/// Schema is from server's perspective - no inversion needed.
351pub fn ts_type_client_return(shape: &'static Shape) -> String {
352    crate::targets::swift::types::assert_no_channels_in_return_shape(shape);
353    ts_type_base_named(shape)
354}
355
356/// Convert Shape to TypeScript type string for server/handler arguments.
357/// Schema is from server's perspective - no inversion needed.
358/// Rx means server receives, Tx means server sends.
359pub fn ts_type_server_arg(shape: &'static Shape) -> String {
360    match classify_shape(shape) {
361        ShapeKind::Tx { inner } => format!("Tx<{}>", ts_type_server_arg(inner)),
362        ShapeKind::Rx { inner } => format!("Rx<{}>", ts_type_server_arg(inner)),
363        _ => ts_type_base_named(shape),
364    }
365}
366
367/// Schema is from server's perspective - no inversion needed.
368pub fn ts_type_server_return(shape: &'static Shape) -> String {
369    crate::targets::swift::types::assert_no_channels_in_return_shape(shape);
370    ts_type_base_named(shape)
371}
372
373/// TypeScript type for user-facing type definitions.
374/// Uses named types when available.
375pub fn ts_type(shape: &'static Shape) -> String {
376    ts_type_base_named(shape)
377}
378
379/// Check if a type can be fully encoded/decoded.
380/// Channel types (Tx/Rx) are supported - they encode as channel IDs.
381pub fn is_fully_supported(shape: &'static Shape) -> bool {
382    match classify_shape(shape) {
383        // Channel types are supported - they encode/decode as channel IDs
384        ShapeKind::Tx { inner } | ShapeKind::Rx { inner } => is_fully_supported(inner),
385        ShapeKind::List { element }
386        | ShapeKind::Option { inner: element }
387        | ShapeKind::Set { element }
388        | ShapeKind::Array { element, .. }
389        | ShapeKind::Slice { element } => is_fully_supported(element),
390        ShapeKind::Map { key, value } => is_fully_supported(key) && is_fully_supported(value),
391        ShapeKind::Tuple { elements } => elements.iter().all(|p| is_fully_supported(p.shape)),
392        ShapeKind::TupleStruct { fields } => fields.iter().all(|f| is_fully_supported(f.shape())),
393        ShapeKind::Struct(StructInfo { fields, .. }) => {
394            fields.iter().all(|f| is_fully_supported(f.shape()))
395        }
396        ShapeKind::Enum(EnumInfo { variants, .. }) => {
397            variants.iter().all(|v| match classify_variant(v) {
398                VariantKind::Unit => true,
399                VariantKind::Newtype { inner } => is_fully_supported(inner),
400                VariantKind::Tuple { fields } | VariantKind::Struct { fields } => {
401                    fields.iter().all(|f| is_fully_supported(f.shape()))
402                }
403            })
404        }
405        ShapeKind::Pointer { pointee } => is_fully_supported(pointee),
406        ShapeKind::Scalar(_) => true,
407        ShapeKind::Result { ok, err } => is_fully_supported(ok) && is_fully_supported(err),
408        ShapeKind::Opaque => false,
409    }
410}