Skip to main content

roam_codegen/targets/typescript/
schema.rs

1//! TypeScript schema generation for runtime service descriptors.
2//!
3//! Generates the `ServiceDescriptor` constant used by the runtime to perform
4//! schema-driven decode/encode for all methods. The generated code has zero
5//! serialization logic — everything is driven by the descriptor at runtime.
6//!
7//! Each method descriptor has:
8//! - `args`: a tuple schema covering all arguments (decoded once before dispatch)
9//! - `result`: the full `Result<T, RoamError<E>>` enum schema for encoding responses
10//!
11//! The `RoamError<E>` schema always has four variants at fixed indices:
12//! - 0: User(E)        — user-defined error (null fields for infallible methods)
13//! - 1: UnknownMethod  — unit
14//! - 2: InvalidPayload — unit
15//! - 3: Cancelled      — unit
16
17use std::collections::HashSet;
18
19use facet_core::{Field, ScalarType, Shape};
20use heck::ToLowerCamelCase;
21use roam_types::{
22    EnumInfo, ServiceDescriptor, ShapeKind, StructInfo, VariantKind, classify_shape,
23    classify_variant, is_bytes,
24};
25
26/// Generate a TypeScript Schema object literal for a type.
27pub fn generate_schema(shape: &'static Shape) -> String {
28    let mut state = SchemaGenState::default();
29    generate_schema_with_field(shape, None, &mut state)
30}
31
32#[derive(Default)]
33struct SchemaGenState {
34    /// When true, named structs/enums are emitted as `{ kind: 'ref', name: 'T' }`
35    /// unless we're currently generating that type's registry entry (`root_name`).
36    use_named_refs: bool,
37    /// Name of the registry entry currently being generated, if any.
38    root_name: Option<String>,
39    /// Active shape pointers for recursion detection.
40    active: HashSet<usize>,
41}
42
43fn named_shape_name(shape: &'static Shape) -> Option<&'static str> {
44    match classify_shape(shape) {
45        ShapeKind::Struct(StructInfo {
46            name: Some(name), ..
47        })
48        | ShapeKind::Enum(EnumInfo {
49            name: Some(name), ..
50        }) => Some(name),
51        _ => None,
52    }
53}
54
55fn generate_schema_with_field(
56    shape: &'static Shape,
57    field: Option<&Field>,
58    state: &mut SchemaGenState,
59) -> String {
60    if state.use_named_refs
61        && let Some(name) = named_shape_name(shape)
62        && state.root_name.as_deref() != Some(name)
63    {
64        return format!("{{ kind: 'ref', name: '{name}' }}");
65    }
66
67    let shape_ptr = shape as *const Shape as usize;
68    if !state.active.insert(shape_ptr) {
69        if let Some(name) = named_shape_name(shape) {
70            return format!("{{ kind: 'ref', name: '{name}' }}");
71        }
72        panic!(
73            "encountered recursive anonymous shape in TypeScript schema generation; \
74             recursive shapes must be named to generate refs"
75        );
76    }
77
78    let bytes_schema = if field.is_some_and(|f| f.has_builtin_attr("trailing")) {
79        "{ kind: 'bytes', trailing: true }"
80    } else {
81        "{ kind: 'bytes' }"
82    };
83
84    // Check for bytes first (Vec<u8>)
85    if is_bytes(shape) {
86        state.active.remove(&shape_ptr);
87        return bytes_schema.into();
88    }
89
90    let rendered = match classify_shape(shape) {
91        ShapeKind::Scalar(scalar) => generate_scalar_schema(scalar),
92        ShapeKind::Tx { inner } => {
93            format!(
94                "{{ kind: 'tx', element: {} }}",
95                generate_schema_with_field(inner, None, state)
96            )
97        }
98        ShapeKind::Rx { inner } => {
99            format!(
100                "{{ kind: 'rx', element: {} }}",
101                generate_schema_with_field(inner, None, state)
102            )
103        }
104        ShapeKind::List { element } => {
105            format!(
106                "{{ kind: 'vec', element: {} }}",
107                generate_schema_with_field(element, None, state)
108            )
109        }
110        ShapeKind::Option { inner } => {
111            format!(
112                "{{ kind: 'option', inner: {} }}",
113                generate_schema_with_field(inner, None, state)
114            )
115        }
116        ShapeKind::Array { element, .. } | ShapeKind::Slice { element } => {
117            format!(
118                "{{ kind: 'vec', element: {} }}",
119                generate_schema_with_field(element, None, state)
120            )
121        }
122        ShapeKind::Map { key, value } => {
123            format!(
124                "{{ kind: 'map', key: {}, value: {} }}",
125                generate_schema_with_field(key, None, state),
126                generate_schema_with_field(value, None, state)
127            )
128        }
129        ShapeKind::Set { element } => {
130            format!(
131                "{{ kind: 'vec', element: {} }}",
132                generate_schema_with_field(element, None, state)
133            )
134        }
135        ShapeKind::Tuple { elements } => {
136            let element_schemas: Vec<_> = elements
137                .iter()
138                .map(|p| generate_schema_with_field(p.shape, None, state))
139                .collect();
140            format!(
141                "{{ kind: 'tuple', elements: [{}] }}",
142                element_schemas.join(", ")
143            )
144        }
145        ShapeKind::Struct(StructInfo { fields, .. }) => {
146            let field_schemas: Vec<_> = fields
147                .iter()
148                .map(|f| {
149                    format!(
150                        "'{}': {}",
151                        f.name,
152                        generate_schema_with_field(f.shape(), Some(f), state)
153                    )
154                })
155                .collect();
156            format!(
157                "{{ kind: 'struct', fields: {{ {} }} }}",
158                field_schemas.join(", ")
159            )
160        }
161        ShapeKind::Enum(EnumInfo { variants, .. }) => {
162            let variant_schemas: Vec<_> = variants
163                .iter()
164                .map(|variant| generate_enum_variant(variant, state))
165                .collect();
166            format!(
167                "{{ kind: 'enum', variants: [{}] }}",
168                variant_schemas.join(", ")
169            )
170        }
171        ShapeKind::Pointer { pointee } => generate_schema_with_field(pointee, None, state),
172        ShapeKind::Result { ok, err } => {
173            // Represent Result as enum with Ok/Err variants
174            format!(
175                "{{ kind: 'enum', variants: [{{ name: 'Ok', fields: {} }}, {{ name: 'Err', fields: {} }}] }}",
176                generate_schema_with_field(ok, None, state),
177                generate_schema_with_field(err, None, state)
178            )
179        }
180        ShapeKind::TupleStruct { fields } => {
181            let inner: Vec<String> = fields
182                .iter()
183                .map(|f| generate_schema_with_field(f.shape(), Some(f), state))
184                .collect();
185            format!("{{ kind: 'tuple', elements: [{}] }}", inner.join(", "))
186        }
187        ShapeKind::Opaque => bytes_schema.into(),
188    };
189
190    state.active.remove(&shape_ptr);
191    rendered
192}
193
194/// Generate an EnumVariant object literal.
195fn generate_enum_variant(variant: &facet_core::Variant, state: &mut SchemaGenState) -> String {
196    match classify_variant(variant) {
197        VariantKind::Unit => {
198            format!("{{ name: '{}', fields: null }}", variant.name)
199        }
200        VariantKind::Newtype { inner } => {
201            let field = variant.data.fields.first();
202            format!(
203                "{{ name: '{}', fields: {} }}",
204                variant.name,
205                generate_schema_with_field(inner, field, state)
206            )
207        }
208        VariantKind::Tuple { fields } => {
209            let field_schemas: Vec<_> = fields
210                .iter()
211                .map(|f| generate_schema_with_field(f.shape(), Some(f), state))
212                .collect();
213            format!(
214                "{{ name: '{}', fields: [{}] }}",
215                variant.name,
216                field_schemas.join(", ")
217            )
218        }
219        VariantKind::Struct { fields } => {
220            let field_schemas: Vec<_> = fields
221                .iter()
222                .map(|f| {
223                    format!(
224                        "'{}': {}",
225                        f.name,
226                        generate_schema_with_field(f.shape(), Some(f), state)
227                    )
228                })
229                .collect();
230            format!(
231                "{{ name: '{}', fields: {{ {} }} }}",
232                variant.name,
233                field_schemas.join(", ")
234            )
235        }
236    }
237}
238
239/// Generate schema for scalar types.
240fn generate_scalar_schema(scalar: ScalarType) -> String {
241    match scalar {
242        ScalarType::Bool => "{ kind: 'bool' }".into(),
243        ScalarType::U8 => "{ kind: 'u8' }".into(),
244        ScalarType::U16 => "{ kind: 'u16' }".into(),
245        ScalarType::U32 => "{ kind: 'u32' }".into(),
246        ScalarType::U64 | ScalarType::USize => "{ kind: 'u64' }".into(),
247        ScalarType::I8 => "{ kind: 'i8' }".into(),
248        ScalarType::I16 => "{ kind: 'i16' }".into(),
249        ScalarType::I32 => "{ kind: 'i32' }".into(),
250        ScalarType::I64 | ScalarType::ISize => "{ kind: 'i64' }".into(),
251        ScalarType::U128 | ScalarType::I128 => {
252            panic!(
253                "u128/i128 types are not supported in TypeScript codegen - use smaller integer types or encode as bytes"
254            )
255        }
256        ScalarType::F32 => "{ kind: 'f32' }".into(),
257        ScalarType::F64 => "{ kind: 'f64' }".into(),
258        ScalarType::Char | ScalarType::Str | ScalarType::String | ScalarType::CowStr => {
259            "{ kind: 'string' }".into()
260        }
261        ScalarType::Unit => "{ kind: 'struct', fields: {} }".into(),
262        _ => "{ kind: 'bytes' }".into(),
263    }
264}
265
266/// Generate the `RoamError<E>` enum schema.
267///
268/// Always has four variants at fixed indices:
269/// - 0: User(E)        — user-defined error (null for infallible)
270/// - 1: UnknownMethod  — unit
271/// - 2: InvalidPayload — unit
272/// - 3: Cancelled      — unit
273fn generate_roam_error_schema(err_schema: &str) -> String {
274    format!(
275        "{{ kind: 'enum', variants: [\
276          {{ name: 'User', fields: {err_schema} }}, \
277          {{ name: 'UnknownMethod', fields: null }}, \
278          {{ name: 'InvalidPayload', fields: null }}, \
279          {{ name: 'Cancelled', fields: null }}\
280        ] }}"
281    )
282}
283
284/// Generate the full `Result<T, RoamError<E>>` enum schema for a method.
285///
286/// - Ok (index 0): T
287/// - Err (index 1): RoamError<E>
288fn generate_result_schema(ok_schema: &str, err_schema: &str) -> String {
289    let roam_error = generate_roam_error_schema(err_schema);
290    format!(
291        "{{ kind: 'enum', variants: [{{ name: 'Ok', fields: {ok_schema} }}, {{ name: 'Err', fields: {roam_error} }}] }}"
292    )
293}
294
295/// Generate the service descriptor constant.
296///
297/// The descriptor contains all method descriptors with their args tuple schemas
298/// and full result schemas. The runtime uses this for schema-driven dispatch.
299pub fn generate_descriptor(service: &ServiceDescriptor) -> String {
300    use super::types::collect_named_types;
301    use crate::render::hex_u64;
302
303    let mut out = String::new();
304    let service_name_lower = service.service_name.to_lower_camel_case();
305    let named_types = collect_named_types(service);
306
307    out.push_str("// Named schema registry (for recursive / shared named types)\n");
308    out.push_str(&format!(
309        "const {service_name_lower}_schema_registry: SchemaRegistry = new Map<string, Schema>([\n"
310    ));
311    for (name, shape) in &named_types {
312        let mut state = SchemaGenState {
313            use_named_refs: true,
314            root_name: Some(name.clone()),
315            active: HashSet::new(),
316        };
317        let schema = generate_schema_with_field(shape, None, &mut state);
318        out.push_str(&format!("  [\"{name}\", {schema}],\n"));
319    }
320    out.push_str("]);\n\n");
321
322    out.push_str("// Service descriptor for runtime schema-driven dispatch\n");
323    out.push_str(&format!(
324        "export const {service_name_lower}_descriptor: ServiceDescriptor = {{\n"
325    ));
326    out.push_str(&format!("  service_name: '{}',\n", service.service_name));
327    out.push_str(&format!(
328        "  schema_registry: {service_name_lower}_schema_registry,\n"
329    ));
330    out.push_str("  methods: [\n");
331
332    for method in service.methods {
333        let method_name = method.method_name.to_lower_camel_case();
334        let id = crate::method_id(method);
335
336        // Args as a tuple schema
337        let mut args_state = SchemaGenState {
338            use_named_refs: true,
339            root_name: None,
340            active: HashSet::new(),
341        };
342        let arg_schemas: Vec<_> = method
343            .args
344            .iter()
345            .map(|a| generate_schema_with_field(a.shape, None, &mut args_state))
346            .collect();
347        let args_schema = format!(
348            "{{ kind: 'tuple', elements: [{}] }}",
349            arg_schemas.join(", ")
350        );
351
352        // Result schema: Result<T, RoamError<E>>
353        let result_schema = match classify_shape(method.return_shape) {
354            ShapeKind::Result { ok, err } => {
355                let mut ok_state = SchemaGenState {
356                    use_named_refs: true,
357                    root_name: None,
358                    active: HashSet::new(),
359                };
360                let mut err_state = SchemaGenState {
361                    use_named_refs: true,
362                    root_name: None,
363                    active: HashSet::new(),
364                };
365                let ok_schema = generate_schema_with_field(ok, None, &mut ok_state);
366                let err_schema = generate_schema_with_field(err, None, &mut err_state);
367                generate_result_schema(&ok_schema, &err_schema)
368            }
369            _ => {
370                // Infallible: ok = return type, err = null (User variant never sent)
371                let mut ok_state = SchemaGenState {
372                    use_named_refs: true,
373                    root_name: None,
374                    active: HashSet::new(),
375                };
376                let ok_schema =
377                    generate_schema_with_field(method.return_shape, None, &mut ok_state);
378                generate_result_schema(&ok_schema, "null")
379            }
380        };
381
382        out.push_str("    {\n");
383        out.push_str(&format!("      name: '{method_name}',\n"));
384        out.push_str(&format!("      id: {}n,\n", hex_u64(id)));
385        out.push_str(&format!("      args: {args_schema},\n"));
386        out.push_str(&format!("      result: {result_schema},\n"));
387        out.push_str("    },\n");
388    }
389
390    out.push_str("  ],\n");
391    out.push_str("};\n\n");
392    out
393}