Skip to main content

phon_codegen/
typescript.rs

1//! TypeScript target: emit the ergonomic type declarations a programmer writes
2//! against, plus the self-describing schema-bytes the peer parses at startup.
3//!
4//! The emitted module depends only on `@bearcove/phon-schema` (the data plane):
5//! the type declarations, a `registry` built from the schema-bytes, and the
6//! per-root `schemaId` constants. Encode/decode wrappers (which need the engine)
7//! belong to the RPC layer that imports this, not here.
8//!
9//! The type shapes match what `decodeTyped` produces (`r[codegen.emits]`):
10//! struct -> interface; enum -> `{ tag, … }` discriminated union; small ints ->
11//! `number`, 64/128-bit -> `bigint`; option -> `T | null`; map -> `Map`; char,
12//! datetime, uuid, qname -> `string`.
13
14use std::collections::HashMap;
15use std::fmt::Write;
16
17use phon_schema::{
18    Primitive, Schema, SchemaKind, SchemaRef, VariantPayload, primitive_id, schema_bundle_to_bytes,
19};
20
21use crate::source::Module;
22
23/// Every primitive, for the id -> tag reverse map (primitive refs carry a real
24/// content-derived id that is not stored in the module's schema list).
25const PRIMITIVES: [Primitive; 21] = [
26    Primitive::Bool,
27    Primitive::U8,
28    Primitive::U16,
29    Primitive::U32,
30    Primitive::U64,
31    Primitive::U128,
32    Primitive::I8,
33    Primitive::I16,
34    Primitive::I32,
35    Primitive::I64,
36    Primitive::I128,
37    Primitive::F32,
38    Primitive::F64,
39    Primitive::Char,
40    Primitive::String,
41    Primitive::Bytes,
42    Primitive::DateTime,
43    Primitive::Uuid,
44    Primitive::QName,
45    Primitive::Unit,
46    Primitive::Never,
47];
48
49/// Render a whole [`Module`] to a single TypeScript source file.
50// r[impl codegen.emits]
51// r[impl codegen.schema-is-source-of-truth]
52#[must_use]
53pub fn render(module: &Module) -> String {
54    let r = Renderer::new(module);
55    r.render()
56}
57
58/// Encode the complete schema closure carried by a generated module.
59///
60/// # Errors
61/// Returns an error if the module's schema graph is not a canonical admitted
62/// bundle.
63pub fn schema_bundle_bytes(module: &Module) -> Result<Vec<u8>, phon_schema::DecodeError> {
64    schema_bundle_to_bytes(&module.schemas)
65}
66
67/// Render JUST a named phon `Registry` for `module`'s schemas (no type
68/// declarations, no `schemaId`). The generated source carries one canonical
69/// `SchemaBundleEnvelope` constant and admits it through the language runtime.
70/// Used by RPC codegen, which supplies its own channel-aware declarations.
71// r[impl codegen.schema-is-source-of-truth]
72#[must_use]
73pub fn render_registry(module: &Module, name: &str) -> String {
74    let bytes = schema_bundle_bytes(module).expect("codegen module schemas form a valid bundle");
75    let mut out = String::new();
76    let _ = writeln!(
77        out,
78        "export const {name}SchemaBundle = hexToBytes(\"{}\");",
79        hex(&bytes)
80    );
81    let _ = writeln!(
82        out,
83        "export const {name} = new Registry(parseSchemaBundle({name}SchemaBundle));"
84    );
85    out
86}
87
88struct Renderer<'m> {
89    module: &'m Module,
90    prims: HashMap<u64, Primitive>,
91}
92
93impl<'m> Renderer<'m> {
94    fn new(module: &'m Module) -> Self {
95        let prims = PRIMITIVES
96            .iter()
97            .map(|&p| (primitive_id(p).as_u64(), p))
98            .collect();
99        Renderer { module, prims }
100    }
101
102    fn render(&self) -> String {
103        let mut out = String::new();
104        out.push_str("// @generated by phon-codegen — do not edit.\n");
105        out.push_str(
106            "// Type declarations + self-describing schema-bytes for the phon engine.\n\n",
107        );
108        out.push_str(
109            "import { Registry, hexToBytes, parseSchemaBundle } from \"@bearcove/phon-schema\";\n\n",
110        );
111
112        // Type declarations: one per named composite (struct/enum), in id order.
113        for schema in &self.module.schemas {
114            if let Some(decl) = self.render_decl(schema) {
115                out.push_str(&decl);
116                out.push('\n');
117            }
118        }
119
120        // One canonical bundle contains the complete generated schema closure.
121        let bytes =
122            schema_bundle_bytes(self.module).expect("codegen module schemas form a valid bundle");
123        let _ = writeln!(
124            out,
125            "export const schemaBundle = hexToBytes(\"{}\");",
126            hex(&bytes)
127        );
128        out.push_str("export const registry = new Registry(parseSchemaBundle(schemaBundle));\n\n");
129
130        // Per-root SchemaId constants (u64 as a bigint literal).
131        out.push_str("export const schemaId = {\n");
132        for root in &self.module.roots {
133            let _ = writeln!(
134                out,
135                "  {}: 0x{:016x}n,",
136                ident(&root.name),
137                root.id.as_u64()
138            );
139        }
140        out.push_str("} as const;\n");
141
142        out
143    }
144
145    /// A top-level declaration for a named composite. Lists/options/maps/etc. are
146    /// inlined at use sites, so they get no declaration.
147    fn render_decl(&self, schema: &Schema) -> Option<String> {
148        match &schema.kind {
149            SchemaKind::Struct { name, fields } => {
150                let mut s = format!("export interface {} {{\n", type_name(name));
151                for f in fields {
152                    let _ = writeln!(
153                        s,
154                        "  {}: {};",
155                        field_key(&f.name),
156                        self.render_ref(&f.schema)
157                    );
158                }
159                s.push_str("}\n");
160                Some(s)
161            }
162            SchemaKind::Enum { name, variants } => {
163                let mut s = format!("export type {} =", type_name(name));
164                for v in variants {
165                    s.push_str("\n  | ");
166                    s.push_str(&self.render_variant(&v.name, &v.payload));
167                }
168                s.push_str(";\n");
169                Some(s)
170            }
171            _ => None,
172        }
173    }
174
175    /// One arm of a discriminated union. Unit -> `{ tag }`; newtype -> `{ tag,
176    /// value }`; tuple -> `{ tag, value: [...] }`; struct -> `{ tag, ...fields }`.
177    fn render_variant(&self, name: &str, payload: &VariantPayload) -> String {
178        let tag = format!("tag: {}", json_str(name));
179        match payload {
180            VariantPayload::Unit => format!("{{ {tag} }}"),
181            VariantPayload::Newtype(r) => format!("{{ {tag}; value: {} }}", self.render_ref(r)),
182            VariantPayload::Tuple(refs) => {
183                let inner: Vec<String> = refs.iter().map(|r| self.render_ref(r)).collect();
184                format!("{{ {tag}; value: [{}] }}", inner.join(", "))
185            }
186            VariantPayload::Struct(fields) => {
187                let mut s = format!("{{ {tag}");
188                for f in fields {
189                    let _ = write!(
190                        s,
191                        "; {}: {}",
192                        field_key(&f.name),
193                        self.render_ref(&f.schema)
194                    );
195                }
196                s.push_str(" }");
197                s
198            }
199        }
200    }
201
202    /// The TS type for a schema reference: a primitive scalar, a named composite,
203    /// or an inlined container.
204    fn render_ref(&self, r: &SchemaRef) -> String {
205        let id = match r {
206            SchemaRef::Concrete { id, .. } => *id,
207            SchemaRef::Var { name } => return format!("/* type var {name} */ unknown"),
208        };
209        if let Some(p) = self.prims.get(&id.as_u64()) {
210            return primitive_ts(*p).to_string();
211        }
212        let Some(schema) = self.module.schema(id) else {
213            return "unknown".to_string();
214        };
215        match &schema.kind {
216            SchemaKind::Struct { name, .. } | SchemaKind::Enum { name, .. } => type_name(name),
217            SchemaKind::List { element } | SchemaKind::Set { element } => {
218                format!("{}[]", self.render_ref_parenthesized(element))
219            }
220            SchemaKind::Array { element, .. } => {
221                format!("{}[]", self.render_ref_parenthesized(element))
222            }
223            SchemaKind::Option { element } => format!("{} | null", self.render_ref(element)),
224            SchemaKind::Map { key, value } => {
225                format!("Map<{}, {}>", self.render_ref(key), self.render_ref(value))
226            }
227            SchemaKind::Tuple { elements } => {
228                let inner: Vec<String> = elements.iter().map(|e| self.render_ref(e)).collect();
229                format!("[{}]", inner.join(", "))
230            }
231            SchemaKind::Semantic { representation, .. } => self.render_ref(representation),
232            SchemaKind::Dynamic => "unknown".to_string(),
233            SchemaKind::Primitive(p) => primitive_ts(*p).to_string(),
234            SchemaKind::Tensor { .. }
235            | SchemaKind::Channel { .. }
236            | SchemaKind::External { .. } => "unknown".to_string(),
237        }
238    }
239
240    /// Like `render_ref` but parenthesizes a union so `T | null` becomes
241    /// `(T | null)[]` rather than the ambiguous `T | null[]`.
242    fn render_ref_parenthesized(&self, r: &SchemaRef) -> String {
243        let t = self.render_ref(r);
244        if t.contains(" | ") {
245            format!("({t})")
246        } else {
247            t
248        }
249    }
250}
251
252fn primitive_ts(p: Primitive) -> &'static str {
253    match p {
254        Primitive::Bool => "boolean",
255        Primitive::U8
256        | Primitive::U16
257        | Primitive::U32
258        | Primitive::I8
259        | Primitive::I16
260        | Primitive::I32
261        | Primitive::F32
262        | Primitive::F64 => "number",
263        Primitive::U64 | Primitive::U128 | Primitive::I64 | Primitive::I128 => "bigint",
264        Primitive::Char
265        | Primitive::String
266        | Primitive::DateTime
267        | Primitive::Uuid
268        | Primitive::QName => "string",
269        Primitive::Bytes => "Uint8Array",
270        Primitive::Unit => "null",
271        Primitive::Never => "never",
272    }
273}
274
275/// A TS type name (the Rust type identifier is already PascalCase-ish).
276fn type_name(name: &str) -> String {
277    name.to_string()
278}
279
280fn hex(bytes: &[u8]) -> String {
281    let mut s = String::with_capacity(bytes.len() * 2);
282    for b in bytes {
283        let _ = write!(s, "{b:02x}");
284    }
285    s
286}
287
288/// A double-quoted JS string literal (escapes the characters a tag/name could
289/// contain).
290fn json_str(s: &str) -> String {
291    let mut out = String::with_capacity(s.len() + 2);
292    out.push('"');
293    for c in s.chars() {
294        match c {
295            '"' => out.push_str("\\\""),
296            '\\' => out.push_str("\\\\"),
297            '\n' => out.push_str("\\n"),
298            '\r' => out.push_str("\\r"),
299            '\t' => out.push_str("\\t"),
300            _ => out.push(c),
301        }
302    }
303    out.push('"');
304    out
305}
306
307/// An object key: bare if a valid JS identifier, else quoted.
308fn field_key(name: &str) -> String {
309    let valid = !name.is_empty()
310        && name
311            .chars()
312            .next()
313            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
314        && name
315            .chars()
316            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$');
317    if valid {
318        name.to_string()
319    } else {
320        json_str(name)
321    }
322}
323
324/// An identifier for a `const` object key (same rule as `field_key`).
325fn ident(name: &str) -> String {
326    field_key(name)
327}