Skip to main content

vox_codegen/targets/typescript/
schema.rs

1//! TypeScript generation for canonical service schema tables and descriptors.
2//!
3//! The generated descriptor carries method metadata plus a precomputed
4//! canonical schema table for method args and responses. Encoding and decoding
5//! are driven entirely by that canonical schema data at runtime.
6
7use facet_core::{Facet, Shape};
8use heck::ToLowerCamelCase;
9use vox_types::{
10    VoxError, Schema, SchemaHash, SchemaKind, ServiceDescriptor, ShapeKind, TypeRef,
11    VariantPayload, VariantSchema, classify_shape,
12};
13
14/// Generate TypeScript constants for canonical service schemas.
15///
16/// Produces the `{service}_send_schemas` export with a `schemas` map
17/// (typeId → Schema object) and a `methods` map (methodId → root refs).
18///
19/// The response schema is ALWAYS wrapped as `Result<T, VoxError<E>>` to match
20/// the actual wire encoding. For infallible methods, E = Infallible, which
21/// extracts to the canonical `never` primitive.
22pub fn generate_send_schema_table(service: &ServiceDescriptor) -> String {
23    use crate::render::hex_u64;
24
25    let service_name_lower = service.service_name.to_lower_camel_case();
26
27    let mut schema_ids_seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
28
29    // Collect all schemas (extracted + constructed) with temporary IDs.
30    // We'll finalize content hashes and CBOR-encode at the end.
31    let mut all_schemas: Vec<Schema> = Vec::new();
32
33    /// Extract schemas for a shape, append to all_schemas, return root TypeRef.
34    fn extract_into(shape: &'static Shape, all_schemas: &mut Vec<Schema>) -> TypeRef<SchemaHash> {
35        let extracted = vox_types::extract_schemas(shape).expect("schema extraction");
36        let root = extracted.root.clone();
37        all_schemas.extend(extracted.schemas);
38        root
39    }
40
41    fn type_id_of(type_ref: &TypeRef<SchemaHash>) -> SchemaHash {
42        match type_ref {
43            TypeRef::Concrete { type_id, .. } => *type_id,
44            TypeRef::Var { .. } => panic!("schema root cannot be a type variable"),
45        }
46    }
47
48    // Track per-method info with full TypeRefs (preserving generic args).
49    struct MethodSchemaInfo {
50        method_id: u64,
51        args_root: TypeRef<SchemaHash>,
52        response_root: TypeRef<SchemaHash>,
53    }
54
55    let mut method_schema_infos: Vec<MethodSchemaInfo> = Vec::new();
56
57    let result_template_root = extract_into(
58        <Result<bool, u32> as Facet<'static>>::SHAPE,
59        &mut all_schemas,
60    );
61    let result_type_id = type_id_of(&result_template_root);
62    let vox_error_template_root = extract_into(
63        <VoxError<std::convert::Infallible> as Facet<'static>>::SHAPE,
64        &mut all_schemas,
65    );
66    let vox_error_type_id = type_id_of(&vox_error_template_root);
67
68    for method in service.methods {
69        let method_id = crate::method_id(method);
70
71        // --- Args ---
72        // Use the macro-provided canonical args tuple shape directly.
73        let args_root = extract_into(method.args_shape, &mut all_schemas);
74
75        // --- Response ---
76        // The wire encoding is ALWAYS Result<T, VoxError<E>>.
77        let (ok_ref, err_ref) = match classify_shape(method.return_shape) {
78            ShapeKind::Result { ok, err } => (
79                extract_into(ok, &mut all_schemas),
80                extract_into(err, &mut all_schemas),
81            ),
82            _ => {
83                let ok = extract_into(method.return_shape, &mut all_schemas);
84                let err = extract_into(
85                    <std::convert::Infallible as Facet<'static>>::SHAPE,
86                    &mut all_schemas,
87                );
88                (ok, err)
89            }
90        };
91
92        let vox_error_ref = TypeRef::generic(vox_error_type_id, vec![err_ref]);
93
94        method_schema_infos.push(MethodSchemaInfo {
95            method_id,
96            args_root,
97            response_root: TypeRef::generic(result_type_id, vec![ok_ref, vox_error_ref]),
98        });
99    }
100
101    // Dedup schemas by ID.
102    let mut deduped_schemas: Vec<&Schema> = Vec::new();
103    for schema in &all_schemas {
104        let id = schema.id.0;
105        if schema_ids_seen.insert(id) {
106            deduped_schemas.push(schema);
107        }
108    }
109
110    // Generate TypeScript output — Schema objects as typed literals, not CBOR bytes.
111    let mut out = String::new();
112
113    out.push_str("// Schema objects for wire schema exchange (TypeScript \u{2192} Rust)\n");
114    out.push_str("// Generated from Rust Facet shapes \u{2014} do not modify.\n");
115    out.push_str(&format!(
116        "export const {service_name_lower}_send_schemas: import(\"@bearcove/vox-core\").ServiceSendSchemas = {{\n"
117    ));
118
119    // schemas: Map<bigint, Schema>
120    out.push_str("  schemas: new Map<bigint, import(\"@bearcove/vox-postcard\").Schema>([\n");
121    for schema in &deduped_schemas {
122        let id_hex = hex_u64(schema.id.0);
123        let schema_ts = render_schema(schema);
124        out.push_str(&format!("    [{id_hex}n, {schema_ts}],\n"));
125    }
126    out.push_str("  ]),\n");
127
128    // methods: Map<bigint, MethodSendSchemas>
129    out.push_str(
130        "  methods: new Map<bigint, import(\"@bearcove/vox-core\").MethodSendSchemas>([\n",
131    );
132    for info in &method_schema_infos {
133        let id_hex = hex_u64(info.method_id);
134        let args_root_ref_ts = render_type_ref(&info.args_root);
135        let response_root_ref_ts = render_type_ref(&info.response_root);
136        out.push_str(&format!(
137            "    [{}n, {{ argsRootRef: {}, responseRootRef: {} }}],\n",
138            id_hex, args_root_ref_ts, response_root_ref_ts,
139        ));
140    }
141    out.push_str("  ]),\n");
142    out.push_str("};\n\n");
143    out
144}
145
146/// Generate the service descriptor constant.
147///
148/// The descriptor carries method metadata plus the canonical service schema
149/// table. Legacy TS-only args/result schemas are no longer emitted here.
150pub fn generate_descriptor(service: &ServiceDescriptor) -> String {
151    use crate::render::hex_u64;
152
153    let mut out = String::new();
154    let service_name_lower = service.service_name.to_lower_camel_case();
155
156    for method in service.methods {
157        let method_name = method.method_name.to_lower_camel_case();
158        let id = crate::method_id(method);
159        let method_descriptor_name = format!("{service_name_lower}_{method_name}_method");
160
161        out.push_str(&format!(
162            "export const {method_descriptor_name}: MethodDescriptor = {{\n"
163        ));
164        out.push_str(&format!("  name: '{method_name}',\n"));
165        out.push_str(&format!("  id: {}n,\n", hex_u64(id)));
166        out.push_str(&format!(
167            "  retry: {{ persist: {}, idem: {} }},\n",
168            method.retry.persist, method.retry.idem
169        ));
170        out.push_str("};\n\n");
171    }
172
173    out.push_str("// Service descriptor for runtime dispatch metadata\n");
174    out.push_str(&format!(
175        "export const {service_name_lower}_descriptor: ServiceDescriptor = {{\n"
176    ));
177    out.push_str(&format!("  service_name: '{}',\n", service.service_name));
178    out.push_str(&format!(
179        "  send_schemas: {service_name_lower}_send_schemas,\n"
180    ));
181    out.push_str("  methods: new Map<bigint, MethodDescriptor>([\n");
182
183    for method in service.methods {
184        let method_name = method.method_name.to_lower_camel_case();
185        let method_descriptor_name = format!("{service_name_lower}_{method_name}_method");
186        out.push_str(&format!(
187            "    [{method_descriptor_name}.id, {method_descriptor_name}],\n"
188        ));
189    }
190
191    out.push_str("  ]),\n");
192    out.push_str("};\n\n");
193    out
194}
195
196// ============================================================================
197// Rendering helpers: Rust Schema → TypeScript object literal strings
198// ============================================================================
199
200use vox_types::{ChannelDirection, FieldSchema, PrimitiveType};
201
202pub(crate) fn render_schema(schema: &Schema) -> String {
203    use crate::render::hex_u64;
204
205    let id_hex = hex_u64(schema.id.0);
206    let type_params = if schema.type_params.is_empty() {
207        "[]".to_string()
208    } else {
209        let params: Vec<String> = schema
210            .type_params
211            .iter()
212            .map(|p| format!("'{}'", p.as_str()))
213            .collect();
214        format!("[{}]", params.join(", "))
215    };
216    let kind = render_schema_kind(&schema.kind);
217    format!("{{ id: {id_hex}n, type_params: {type_params}, kind: {kind} }}")
218}
219
220fn render_schema_kind(kind: &SchemaKind) -> String {
221    match kind {
222        SchemaKind::Struct { name, fields } => {
223            let fields_ts: Vec<String> = fields.iter().map(render_field_schema).collect();
224            format!(
225                "{{ tag: 'struct', name: '{}', fields: [{}] }}",
226                name,
227                fields_ts.join(", ")
228            )
229        }
230        SchemaKind::Enum { name, variants } => {
231            let variants_ts: Vec<String> = variants.iter().map(render_variant_schema).collect();
232            format!(
233                "{{ tag: 'enum', name: '{}', variants: [{}] }}",
234                name,
235                variants_ts.join(", ")
236            )
237        }
238        SchemaKind::Tuple { elements } => {
239            let elems: Vec<String> = elements.iter().map(render_type_ref).collect();
240            format!("{{ tag: 'tuple', elements: [{}] }}", elems.join(", "))
241        }
242        SchemaKind::List { element } => {
243            format!("{{ tag: 'list', element: {} }}", render_type_ref(element))
244        }
245        SchemaKind::Map { key, value } => {
246            format!(
247                "{{ tag: 'map', key: {}, value: {} }}",
248                render_type_ref(key),
249                render_type_ref(value)
250            )
251        }
252        SchemaKind::Array { element, length } => {
253            format!(
254                "{{ tag: 'array', element: {}, length: {} }}",
255                render_type_ref(element),
256                length
257            )
258        }
259        SchemaKind::Option { element } => {
260            format!("{{ tag: 'option', element: {} }}", render_type_ref(element))
261        }
262        SchemaKind::Channel { direction, element } => {
263            let dir = match direction {
264                ChannelDirection::Tx => "tx",
265                ChannelDirection::Rx => "rx",
266            };
267            format!(
268                "{{ tag: 'channel', direction: '{}', element: {} }}",
269                dir,
270                render_type_ref(element)
271            )
272        }
273        SchemaKind::Primitive { primitive_type } => {
274            format!(
275                "{{ tag: 'primitive', primitive_type: '{}' }}",
276                render_primitive_type(primitive_type)
277            )
278        }
279    }
280}
281
282pub(crate) fn render_type_ref(type_ref: &TypeRef) -> String {
283    use crate::render::hex_u64;
284
285    match type_ref {
286        TypeRef::Concrete { type_id, args } => {
287            let id_hex = hex_u64(type_id.0);
288            let args_ts: Vec<String> = args.iter().map(render_type_ref).collect();
289            format!(
290                "{{ tag: 'concrete', type_id: {id_hex}n, args: [{}] }}",
291                args_ts.join(", ")
292            )
293        }
294        TypeRef::Var { name } => {
295            format!("{{ tag: 'var', name: '{}' }}", name.as_str())
296        }
297    }
298}
299
300fn render_field_schema(field: &FieldSchema) -> String {
301    format!(
302        "{{ name: '{}', type_ref: {}, required: {} }}",
303        field.name,
304        render_type_ref(&field.type_ref),
305        field.required
306    )
307}
308
309fn render_variant_schema(variant: &VariantSchema) -> String {
310    format!(
311        "{{ name: '{}', index: {}, payload: {} }}",
312        variant.name,
313        variant.index,
314        render_variant_payload(&variant.payload)
315    )
316}
317
318fn render_variant_payload(payload: &VariantPayload) -> String {
319    match payload {
320        VariantPayload::Unit => "{ tag: 'unit' }".to_string(),
321        VariantPayload::Newtype { type_ref } => {
322            format!(
323                "{{ tag: 'newtype', type_ref: {} }}",
324                render_type_ref(type_ref)
325            )
326        }
327        VariantPayload::Tuple { types } => {
328            let types_ts: Vec<String> = types.iter().map(render_type_ref).collect();
329            format!("{{ tag: 'tuple', types: [{}] }}", types_ts.join(", "))
330        }
331        VariantPayload::Struct { fields } => {
332            let fields_ts: Vec<String> = fields.iter().map(render_field_schema).collect();
333            format!("{{ tag: 'struct', fields: [{}] }}", fields_ts.join(", "))
334        }
335    }
336}
337
338fn render_primitive_type(pt: &PrimitiveType) -> &'static str {
339    match pt {
340        PrimitiveType::Bool => "bool",
341        PrimitiveType::U8 => "u8",
342        PrimitiveType::U16 => "u16",
343        PrimitiveType::U32 => "u32",
344        PrimitiveType::U64 => "u64",
345        PrimitiveType::U128 => "u128",
346        PrimitiveType::I8 => "i8",
347        PrimitiveType::I16 => "i16",
348        PrimitiveType::I32 => "i32",
349        PrimitiveType::I64 => "i64",
350        PrimitiveType::I128 => "i128",
351        PrimitiveType::F32 => "f32",
352        PrimitiveType::F64 => "f64",
353        PrimitiveType::Char => "char",
354        PrimitiveType::String => "string",
355        PrimitiveType::Unit => "unit",
356        PrimitiveType::Never => "never",
357        PrimitiveType::Bytes => "bytes",
358        PrimitiveType::Payload => "payload",
359    }
360}