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 facet_core::{Field, ScalarType, Shape};
18use heck::ToLowerCamelCase;
19use roam_types::{
20    EnumInfo, ServiceDescriptor, ShapeKind, StructInfo, VariantKind, classify_shape,
21    classify_variant, is_bytes,
22};
23
24/// Generate a TypeScript Schema object literal for a type.
25pub fn generate_schema(shape: &'static Shape) -> String {
26    generate_schema_with_field(shape, None)
27}
28
29fn generate_schema_with_field(shape: &'static Shape, field: Option<&Field>) -> String {
30    let bytes_schema = if field.is_some_and(|f| f.has_builtin_attr("trailing")) {
31        "{ kind: 'bytes', trailing: true }"
32    } else {
33        "{ kind: 'bytes' }"
34    };
35
36    // Check for bytes first (Vec<u8>)
37    if is_bytes(shape) {
38        return bytes_schema.into();
39    }
40
41    match classify_shape(shape) {
42        ShapeKind::Scalar(scalar) => generate_scalar_schema(scalar),
43        ShapeKind::Tx { inner } => {
44            format!(
45                "{{ kind: 'tx', element: {} }}",
46                generate_schema_with_field(inner, None)
47            )
48        }
49        ShapeKind::Rx { inner } => {
50            format!(
51                "{{ kind: 'rx', element: {} }}",
52                generate_schema_with_field(inner, None)
53            )
54        }
55        ShapeKind::List { element } => {
56            format!(
57                "{{ kind: 'vec', element: {} }}",
58                generate_schema_with_field(element, None)
59            )
60        }
61        ShapeKind::Option { inner } => {
62            format!(
63                "{{ kind: 'option', inner: {} }}",
64                generate_schema_with_field(inner, None)
65            )
66        }
67        ShapeKind::Array { element, .. } | ShapeKind::Slice { element } => {
68            format!(
69                "{{ kind: 'vec', element: {} }}",
70                generate_schema_with_field(element, None)
71            )
72        }
73        ShapeKind::Map { key, value } => {
74            format!(
75                "{{ kind: 'map', key: {}, value: {} }}",
76                generate_schema_with_field(key, None),
77                generate_schema_with_field(value, None)
78            )
79        }
80        ShapeKind::Set { element } => {
81            format!(
82                "{{ kind: 'vec', element: {} }}",
83                generate_schema_with_field(element, None)
84            )
85        }
86        ShapeKind::Tuple { elements } => {
87            let element_schemas: Vec<_> = elements
88                .iter()
89                .map(|p| generate_schema_with_field(p.shape, None))
90                .collect();
91            format!(
92                "{{ kind: 'tuple', elements: [{}] }}",
93                element_schemas.join(", ")
94            )
95        }
96        ShapeKind::Struct(StructInfo { fields, .. }) => {
97            let field_schemas: Vec<_> = fields
98                .iter()
99                .map(|f| {
100                    format!(
101                        "'{}': {}",
102                        f.name,
103                        generate_schema_with_field(f.shape(), Some(f))
104                    )
105                })
106                .collect();
107            format!(
108                "{{ kind: 'struct', fields: {{ {} }} }}",
109                field_schemas.join(", ")
110            )
111        }
112        ShapeKind::Enum(EnumInfo { variants, .. }) => {
113            let variant_schemas: Vec<_> = variants.iter().map(generate_enum_variant).collect();
114            format!(
115                "{{ kind: 'enum', variants: [{}] }}",
116                variant_schemas.join(", ")
117            )
118        }
119        ShapeKind::Pointer { pointee } => generate_schema(pointee),
120        ShapeKind::Result { ok, err } => {
121            // Represent Result as enum with Ok/Err variants
122            format!(
123                "{{ kind: 'enum', variants: [{{ name: 'Ok', fields: {} }}, {{ name: 'Err', fields: {} }}] }}",
124                generate_schema_with_field(ok, None),
125                generate_schema_with_field(err, None)
126            )
127        }
128        ShapeKind::TupleStruct { fields } => {
129            let inner: Vec<String> = fields
130                .iter()
131                .map(|f| generate_schema_with_field(f.shape(), Some(f)))
132                .collect();
133            format!("{{ kind: 'tuple', elements: [{}] }}", inner.join(", "))
134        }
135        ShapeKind::Opaque => bytes_schema.into(),
136    }
137}
138
139/// Generate an EnumVariant object literal.
140fn generate_enum_variant(variant: &facet_core::Variant) -> String {
141    match classify_variant(variant) {
142        VariantKind::Unit => {
143            format!("{{ name: '{}', fields: null }}", variant.name)
144        }
145        VariantKind::Newtype { inner } => {
146            let field = variant.data.fields.first();
147            format!(
148                "{{ name: '{}', fields: {} }}",
149                variant.name,
150                generate_schema_with_field(inner, field)
151            )
152        }
153        VariantKind::Tuple { fields } => {
154            let field_schemas: Vec<_> = fields
155                .iter()
156                .map(|f| generate_schema_with_field(f.shape(), Some(f)))
157                .collect();
158            format!(
159                "{{ name: '{}', fields: [{}] }}",
160                variant.name,
161                field_schemas.join(", ")
162            )
163        }
164        VariantKind::Struct { fields } => {
165            let field_schemas: Vec<_> = fields
166                .iter()
167                .map(|f| {
168                    format!(
169                        "'{}': {}",
170                        f.name,
171                        generate_schema_with_field(f.shape(), Some(f))
172                    )
173                })
174                .collect();
175            format!(
176                "{{ name: '{}', fields: {{ {} }} }}",
177                variant.name,
178                field_schemas.join(", ")
179            )
180        }
181    }
182}
183
184/// Generate schema for scalar types.
185fn generate_scalar_schema(scalar: ScalarType) -> String {
186    match scalar {
187        ScalarType::Bool => "{ kind: 'bool' }".into(),
188        ScalarType::U8 => "{ kind: 'u8' }".into(),
189        ScalarType::U16 => "{ kind: 'u16' }".into(),
190        ScalarType::U32 => "{ kind: 'u32' }".into(),
191        ScalarType::U64 | ScalarType::USize => "{ kind: 'u64' }".into(),
192        ScalarType::I8 => "{ kind: 'i8' }".into(),
193        ScalarType::I16 => "{ kind: 'i16' }".into(),
194        ScalarType::I32 => "{ kind: 'i32' }".into(),
195        ScalarType::I64 | ScalarType::ISize => "{ kind: 'i64' }".into(),
196        ScalarType::U128 | ScalarType::I128 => {
197            panic!(
198                "u128/i128 types are not supported in TypeScript codegen - use smaller integer types or encode as bytes"
199            )
200        }
201        ScalarType::F32 => "{ kind: 'f32' }".into(),
202        ScalarType::F64 => "{ kind: 'f64' }".into(),
203        ScalarType::Char | ScalarType::Str | ScalarType::String | ScalarType::CowStr => {
204            "{ kind: 'string' }".into()
205        }
206        ScalarType::Unit => "{ kind: 'struct', fields: {} }".into(),
207        _ => "{ kind: 'bytes' }".into(),
208    }
209}
210
211/// Generate the `RoamError<E>` enum schema.
212///
213/// Always has four variants at fixed indices:
214/// - 0: User(E)        — user-defined error (null for infallible)
215/// - 1: UnknownMethod  — unit
216/// - 2: InvalidPayload — unit
217/// - 3: Cancelled      — unit
218fn generate_roam_error_schema(err_schema: &str) -> String {
219    format!(
220        "{{ kind: 'enum', variants: [\
221          {{ name: 'User', fields: {err_schema} }}, \
222          {{ name: 'UnknownMethod', fields: null }}, \
223          {{ name: 'InvalidPayload', fields: null }}, \
224          {{ name: 'Cancelled', fields: null }}\
225        ] }}"
226    )
227}
228
229/// Generate the full `Result<T, RoamError<E>>` enum schema for a method.
230///
231/// - Ok (index 0): T
232/// - Err (index 1): RoamError<E>
233fn generate_result_schema(ok_schema: &str, err_schema: &str) -> String {
234    let roam_error = generate_roam_error_schema(err_schema);
235    format!(
236        "{{ kind: 'enum', variants: [{{ name: 'Ok', fields: {ok_schema} }}, {{ name: 'Err', fields: {roam_error} }}] }}"
237    )
238}
239
240/// Generate the service descriptor constant.
241///
242/// The descriptor contains all method descriptors with their args tuple schemas
243/// and full result schemas. The runtime uses this for schema-driven dispatch.
244pub fn generate_descriptor(service: &ServiceDescriptor) -> String {
245    use crate::render::hex_u64;
246
247    let mut out = String::new();
248    let service_name_lower = service.service_name.to_lower_camel_case();
249
250    out.push_str("// Service descriptor for runtime schema-driven dispatch\n");
251    out.push_str(&format!(
252        "export const {service_name_lower}_descriptor: ServiceDescriptor = {{\n"
253    ));
254    out.push_str(&format!("  service_name: '{}',\n", service.service_name));
255    out.push_str("  methods: [\n");
256
257    for method in service.methods {
258        let method_name = method.method_name.to_lower_camel_case();
259        let id = crate::method_id(method);
260
261        // Args as a tuple schema
262        let arg_schemas: Vec<_> = method
263            .args
264            .iter()
265            .map(|a| generate_schema(a.shape))
266            .collect();
267        let args_schema = format!(
268            "{{ kind: 'tuple', elements: [{}] }}",
269            arg_schemas.join(", ")
270        );
271
272        // Result schema: Result<T, RoamError<E>>
273        let result_schema = match classify_shape(method.return_shape) {
274            ShapeKind::Result { ok, err } => {
275                let ok_schema = generate_schema(ok);
276                let err_schema = generate_schema(err);
277                generate_result_schema(&ok_schema, &err_schema)
278            }
279            _ => {
280                // Infallible: ok = return type, err = null (User variant never sent)
281                let ok_schema = generate_schema(method.return_shape);
282                generate_result_schema(&ok_schema, "null")
283            }
284        };
285
286        out.push_str("    {\n");
287        out.push_str(&format!("      name: '{method_name}',\n"));
288        out.push_str(&format!("      id: {}n,\n", hex_u64(id)));
289        out.push_str(&format!("      args: {args_schema},\n"));
290        out.push_str(&format!("      result: {result_schema},\n"));
291        out.push_str("    },\n");
292    }
293
294    out.push_str("  ],\n");
295    out.push_str("};\n\n");
296    out
297}