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