teamy-phon-codegen 0.2.0-rc.9

The phon codegen tool: reads Rust types (facet) or schema bundles, emits target-language source plus schema-bytes constants.
Documentation
//! TypeScript target: emit the ergonomic type declarations a programmer writes
//! against, plus the self-describing schema-bytes the peer parses at startup.
//!
//! The emitted module depends only on `@bearcove/phon-schema` (the data plane):
//! the type declarations, a `registry` built from the schema-bytes, and the
//! per-root `schemaId` constants. Encode/decode wrappers (which need the engine)
//! belong to the RPC layer that imports this, not here.
//!
//! The type shapes match what `decodeTyped` produces (`r[codegen.emits]`):
//! struct -> interface; enum -> `{ tag, … }` discriminated union; small ints ->
//! `number`, 64/128-bit -> `bigint`; option -> `T | null`; map -> `Map`; char,
//! datetime, uuid, qname -> `string`.

use std::collections::HashMap;
use std::fmt::Write;

use phon_schema::{
    Primitive, Schema, SchemaKind, SchemaRef, VariantPayload, primitive_id, schema_bundle_to_bytes,
};

use crate::source::Module;

/// Every primitive, for the id -> tag reverse map (primitive refs carry a real
/// content-derived id that is not stored in the module's schema list).
const PRIMITIVES: [Primitive; 21] = [
    Primitive::Bool,
    Primitive::U8,
    Primitive::U16,
    Primitive::U32,
    Primitive::U64,
    Primitive::U128,
    Primitive::I8,
    Primitive::I16,
    Primitive::I32,
    Primitive::I64,
    Primitive::I128,
    Primitive::F32,
    Primitive::F64,
    Primitive::Char,
    Primitive::String,
    Primitive::Bytes,
    Primitive::DateTime,
    Primitive::Uuid,
    Primitive::QName,
    Primitive::Unit,
    Primitive::Never,
];

/// Render a whole [`Module`] to a single TypeScript source file.
// r[impl codegen.emits]
// r[impl codegen.schema-is-source-of-truth]
#[must_use]
pub fn render(module: &Module) -> String {
    let r = Renderer::new(module);
    r.render()
}

/// Encode the complete schema closure carried by a generated module.
///
/// # Errors
/// Returns an error if the module's schema graph is not a canonical admitted
/// bundle.
pub fn schema_bundle_bytes(module: &Module) -> Result<Vec<u8>, phon_schema::DecodeError> {
    schema_bundle_to_bytes(&module.schemas)
}

/// Render JUST a named phon `Registry` for `module`'s schemas (no type
/// declarations, no `schemaId`). The generated source carries one canonical
/// `SchemaBundleEnvelope` constant and admits it through the language runtime.
/// Used by RPC codegen, which supplies its own channel-aware declarations.
// r[impl codegen.schema-is-source-of-truth]
#[must_use]
pub fn render_registry(module: &Module, name: &str) -> String {
    let bytes = schema_bundle_bytes(module).expect("codegen module schemas form a valid bundle");
    let mut out = String::new();
    let _ = writeln!(
        out,
        "export const {name}SchemaBundle = hexToBytes(\"{}\");",
        hex(&bytes)
    );
    let _ = writeln!(
        out,
        "export const {name} = new Registry(parseSchemaBundle({name}SchemaBundle));"
    );
    out
}

struct Renderer<'m> {
    module: &'m Module,
    prims: HashMap<u64, Primitive>,
}

impl<'m> Renderer<'m> {
    fn new(module: &'m Module) -> Self {
        let prims = PRIMITIVES
            .iter()
            .map(|&p| (primitive_id(p).as_u64(), p))
            .collect();
        Renderer { module, prims }
    }

    fn render(&self) -> String {
        let mut out = String::new();
        out.push_str("// @generated by phon-codegen — do not edit.\n");
        out.push_str(
            "// Type declarations + self-describing schema-bytes for the phon engine.\n\n",
        );
        out.push_str(
            "import { Registry, hexToBytes, parseSchemaBundle } from \"@bearcove/phon-schema\";\n\n",
        );

        // Type declarations: one per named composite (struct/enum), in id order.
        for schema in &self.module.schemas {
            if let Some(decl) = self.render_decl(schema) {
                out.push_str(&decl);
                out.push('\n');
            }
        }

        // One canonical bundle contains the complete generated schema closure.
        let bytes =
            schema_bundle_bytes(self.module).expect("codegen module schemas form a valid bundle");
        let _ = writeln!(
            out,
            "export const schemaBundle = hexToBytes(\"{}\");",
            hex(&bytes)
        );
        out.push_str("export const registry = new Registry(parseSchemaBundle(schemaBundle));\n\n");

        // Per-root SchemaId constants (u64 as a bigint literal).
        out.push_str("export const schemaId = {\n");
        for root in &self.module.roots {
            let _ = writeln!(
                out,
                "  {}: 0x{:016x}n,",
                ident(&root.name),
                root.id.as_u64()
            );
        }
        out.push_str("} as const;\n");

        out
    }

    /// A top-level declaration for a named composite. Lists/options/maps/etc. are
    /// inlined at use sites, so they get no declaration.
    fn render_decl(&self, schema: &Schema) -> Option<String> {
        match &schema.kind {
            SchemaKind::Struct { name, fields } => {
                let mut s = format!("export interface {} {{\n", type_name(name));
                for f in fields {
                    let _ = writeln!(
                        s,
                        "  {}: {};",
                        field_key(&f.name),
                        self.render_ref(&f.schema)
                    );
                }
                s.push_str("}\n");
                Some(s)
            }
            SchemaKind::Enum { name, variants } => {
                let mut s = format!("export type {} =", type_name(name));
                for v in variants {
                    s.push_str("\n  | ");
                    s.push_str(&self.render_variant(&v.name, &v.payload));
                }
                s.push_str(";\n");
                Some(s)
            }
            _ => None,
        }
    }

    /// One arm of a discriminated union. Unit -> `{ tag }`; newtype -> `{ tag,
    /// value }`; tuple -> `{ tag, value: [...] }`; struct -> `{ tag, ...fields }`.
    fn render_variant(&self, name: &str, payload: &VariantPayload) -> String {
        let tag = format!("tag: {}", json_str(name));
        match payload {
            VariantPayload::Unit => format!("{{ {tag} }}"),
            VariantPayload::Newtype(r) => format!("{{ {tag}; value: {} }}", self.render_ref(r)),
            VariantPayload::Tuple(refs) => {
                let inner: Vec<String> = refs.iter().map(|r| self.render_ref(r)).collect();
                format!("{{ {tag}; value: [{}] }}", inner.join(", "))
            }
            VariantPayload::Struct(fields) => {
                let mut s = format!("{{ {tag}");
                for f in fields {
                    let _ = write!(
                        s,
                        "; {}: {}",
                        field_key(&f.name),
                        self.render_ref(&f.schema)
                    );
                }
                s.push_str(" }");
                s
            }
        }
    }

    /// The TS type for a schema reference: a primitive scalar, a named composite,
    /// or an inlined container.
    fn render_ref(&self, r: &SchemaRef) -> String {
        let id = match r {
            SchemaRef::Concrete { id, .. } => *id,
            SchemaRef::Var { name } => return format!("/* type var {name} */ unknown"),
        };
        if let Some(p) = self.prims.get(&id.as_u64()) {
            return primitive_ts(*p).to_string();
        }
        let Some(schema) = self.module.schema(id) else {
            return "unknown".to_string();
        };
        match &schema.kind {
            SchemaKind::Struct { name, .. } | SchemaKind::Enum { name, .. } => type_name(name),
            SchemaKind::List { element } | SchemaKind::Set { element } => {
                format!("{}[]", self.render_ref_parenthesized(element))
            }
            SchemaKind::Array { element, .. } => {
                format!("{}[]", self.render_ref_parenthesized(element))
            }
            SchemaKind::Option { element } => format!("{} | null", self.render_ref(element)),
            SchemaKind::Map { key, value } => {
                format!("Map<{}, {}>", self.render_ref(key), self.render_ref(value))
            }
            SchemaKind::Tuple { elements } => {
                let inner: Vec<String> = elements.iter().map(|e| self.render_ref(e)).collect();
                format!("[{}]", inner.join(", "))
            }
            SchemaKind::Semantic { representation, .. } => self.render_ref(representation),
            SchemaKind::Dynamic => "unknown".to_string(),
            SchemaKind::Primitive(p) => primitive_ts(*p).to_string(),
            SchemaKind::Tensor { .. }
            | SchemaKind::Channel { .. }
            | SchemaKind::External { .. } => "unknown".to_string(),
        }
    }

    /// Like `render_ref` but parenthesizes a union so `T | null` becomes
    /// `(T | null)[]` rather than the ambiguous `T | null[]`.
    fn render_ref_parenthesized(&self, r: &SchemaRef) -> String {
        let t = self.render_ref(r);
        if t.contains(" | ") {
            format!("({t})")
        } else {
            t
        }
    }
}

fn primitive_ts(p: Primitive) -> &'static str {
    match p {
        Primitive::Bool => "boolean",
        Primitive::U8
        | Primitive::U16
        | Primitive::U32
        | Primitive::I8
        | Primitive::I16
        | Primitive::I32
        | Primitive::F32
        | Primitive::F64 => "number",
        Primitive::U64 | Primitive::U128 | Primitive::I64 | Primitive::I128 => "bigint",
        Primitive::Char
        | Primitive::String
        | Primitive::DateTime
        | Primitive::Uuid
        | Primitive::QName => "string",
        Primitive::Bytes => "Uint8Array",
        Primitive::Unit => "null",
        Primitive::Never => "never",
    }
}

/// A TS type name (the Rust type identifier is already PascalCase-ish).
fn type_name(name: &str) -> String {
    name.to_string()
}

fn hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        let _ = write!(s, "{b:02x}");
    }
    s
}

/// A double-quoted JS string literal (escapes the characters a tag/name could
/// contain).
fn json_str(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            _ => out.push(c),
        }
    }
    out.push('"');
    out
}

/// An object key: bare if a valid JS identifier, else quoted.
fn field_key(name: &str) -> String {
    let valid = !name.is_empty()
        && name
            .chars()
            .next()
            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$');
    if valid {
        name.to_string()
    } else {
        json_str(name)
    }
}

/// An identifier for a `const` object key (same rule as `field_key`).
fn ident(name: &str) -> String {
    field_key(name)
}