typebox 0.1.0

JSON Schema type construction with validation, code generation, and binary layout
Documentation
use crate::codegen::SchemaRegistry;
use crate::schema::{LiteralValue, Schema, SchemaKind};
use handlebars::Handlebars;
use serde::Serialize;
use std::collections::HashMap;

pub struct TypeScriptGenerator {
    registry: Handlebars<'static>,
}

impl TypeScriptGenerator {
    pub fn new() -> Self {
        let mut registry = Handlebars::new();
        registry
            .register_template_string("module", MODULE_TEMPLATE)
            .unwrap();
        registry
            .register_template_string("interface", INTERFACE_TEMPLATE)
            .unwrap();
        registry
            .register_template_string("enum", ENUM_TEMPLATE)
            .unwrap();
        registry
            .register_template_string("type", TYPE_TEMPLATE)
            .unwrap();

        Self { registry }
    }

    pub fn generate(&self, name: &str, schema: &Schema) -> Result<String, crate::Error> {
        let context = SchemaContext::from_schema(name, schema);

        match &schema.kind {
            SchemaKind::Enum { values } => {
                let ctx = EnumContext {
                    name: name.to_string(),
                    values: values.clone(),
                };
                Ok(self.registry.render("enum", &ctx)?)
            }
            SchemaKind::Object { .. } => Ok(self.registry.render("interface", &context)?),
            SchemaKind::Union { .. } => {
                let ts_type = schema_to_ts_type(schema, &HashMap::new());
                let ctx = TypeContext {
                    name: name.to_string(),
                    ts_type,
                };
                Ok(self.registry.render("type", &ctx)?)
            }
            SchemaKind::Named { schema, .. } => self.generate(name, schema),
            _ => {
                let ts_type = schema_to_ts_type(schema, &HashMap::new());
                let ctx = TypeContext {
                    name: name.to_string(),
                    ts_type,
                };
                Ok(self.registry.render("type", &ctx)?)
            }
        }
    }

    pub fn generate_module(&self, registry: &SchemaRegistry) -> Result<String, crate::Error> {
        let mut rendered: Vec<String> = Vec::new();

        for (name, schema) in registry.schemas() {
            rendered.push(self.generate(name, schema)?);
        }

        let mut output = String::new();
        output.push_str("// Auto-generated by typebox-rs. DO NOT EDIT.\n\n");

        for code in rendered {
            output.push_str(&code);
            output.push('\n');
        }

        Ok(output)
    }
}

impl Default for TypeScriptGenerator {
    fn default() -> Self {
        Self::new()
    }
}

#[derive(Serialize)]
struct SchemaContext {
    name: String,
    description: Option<String>,
    properties: Vec<PropertyContext>,
    type_refs: HashMap<String, String>,
}

#[derive(Serialize)]
struct PropertyContext {
    name: String,
    ts_type: String,
    optional: bool,
    description: Option<String>,
}

#[derive(Serialize)]
struct EnumContext {
    name: String,
    values: Vec<String>,
}

#[derive(Serialize)]
struct TypeContext {
    name: String,
    ts_type: String,
}

impl SchemaContext {
    fn from_schema(name: &str, schema: &Schema) -> Self {
        let mut properties = Vec::new();

        if let SchemaKind::Object {
            properties: props,
            required,
            ..
        } = &schema.kind
        {
            for (prop_name, prop_schema) in props {
                let is_optional = !required.contains(prop_name);
                properties.push(PropertyContext {
                    name: prop_name.clone(),
                    ts_type: schema_to_ts_type(prop_schema, &HashMap::new()),
                    optional: is_optional,
                    description: None,
                });
            }
        }

        Self {
            name: name.to_string(),
            description: schema.description.clone(),
            properties,
            type_refs: HashMap::new(),
        }
    }
}

fn schema_to_ts_type(schema: &Schema, refs: &HashMap<String, String>) -> String {
    match &schema.kind {
        SchemaKind::Null => "null".to_string(),
        SchemaKind::Bool => "boolean".to_string(),
        SchemaKind::Int8 { .. }
        | SchemaKind::Int16 { .. }
        | SchemaKind::Int32 { .. }
        | SchemaKind::Int64 { .. } => "number".to_string(),
        SchemaKind::UInt8 { .. }
        | SchemaKind::UInt16 { .. }
        | SchemaKind::UInt32 { .. }
        | SchemaKind::UInt64 { .. } => "number".to_string(),
        SchemaKind::Float32 { .. } | SchemaKind::Float64 { .. } => "number".to_string(),
        SchemaKind::String { .. } => "string".to_string(),
        SchemaKind::Bytes { .. } => "Uint8Array".to_string(),

        SchemaKind::Array { items, .. } => {
            format!("Array<{}>", schema_to_ts_type(items, refs))
        }

        SchemaKind::Tuple { items } => {
            let types: Vec<_> = items.iter().map(|s| schema_to_ts_type(s, refs)).collect();
            format!("[{}]", types.join(", "))
        }

        SchemaKind::Object { .. } => "Record<string, unknown>".to_string(),

        SchemaKind::Union { any_of } => {
            if any_of.len() == 2 {
                let is_optional = any_of.iter().any(|s| matches!(&s.kind, SchemaKind::Null));
                if is_optional {
                    let non_null: Vec<_> = any_of
                        .iter()
                        .filter(|s| !matches!(&s.kind, SchemaKind::Null))
                        .collect();
                    if non_null.len() == 1 {
                        return format!("{} | null", schema_to_ts_type(non_null[0], refs));
                    }
                }
            }
            let types: Vec<_> = any_of.iter().map(|s| schema_to_ts_type(s, refs)).collect();
            types.join(" | ")
        }

        SchemaKind::Literal { value } => match value {
            LiteralValue::String(s) => format!("'{}'", s),
            LiteralValue::Number(n) => format!("{}", n),
            LiteralValue::Float(f) => format!("{}", f),
            LiteralValue::Boolean(b) => format!("{}", b),
            LiteralValue::Null => "null".to_string(),
        },

        SchemaKind::Enum { .. } => "string".to_string(),

        SchemaKind::Ref { reference } => {
            let name = reference
                .strip_prefix("#/definitions/")
                .unwrap_or(reference);
            refs.get(name).cloned().unwrap_or_else(|| name.to_string())
        }

        SchemaKind::Named { name, .. } => name.clone(),

        SchemaKind::Function {
            parameters,
            returns,
        } => {
            let params: Vec<_> = parameters
                .iter()
                .map(|s| schema_to_ts_type(s, refs))
                .collect();
            let ret = schema_to_ts_type(returns, refs);
            format!("({}) => {}", params.join(", "), ret)
        }

        SchemaKind::Void => "void".to_string(),
        SchemaKind::Never => "never".to_string(),
        SchemaKind::Any => "any".to_string(),
        SchemaKind::Unknown => "unknown".to_string(),
        SchemaKind::Undefined => "undefined".to_string(),
        SchemaKind::Recursive { schema } => schema_to_ts_type(schema, refs),
        SchemaKind::Intersect { all_of } => {
            let types: Vec<_> = all_of.iter().map(|s| schema_to_ts_type(s, refs)).collect();
            types.join(" & ")
        }
    }
}

const MODULE_TEMPLATE: &str = r#"{{preamble}}{{#each schemas}}
{{{this}}}
{{/each}}"#;

const INTERFACE_TEMPLATE: &str = r#"{{#if description}}/** {{description}} */
{{/if}}export interface {{name}} {
{{#each properties}}
  {{#if description}}/** {{description}} */
  {{/if}}{{name}}{{#if optional}}?{{/if}}: {{ts_type}};
{{/each}}
}
"#;

const ENUM_TEMPLATE: &str = r#"export type {{name}} = {{#each values}}'{{this}}'{{#unless @last}} | {{/unless}}{{/each}};
"#;

const TYPE_TEMPLATE: &str = r#"export type {{name}} = {{{ts_type}}};
"#;

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builder::SchemaBuilder;

    #[test]
    fn test_generate_interface() {
        let gen = TypeScriptGenerator::new();
        let schema = SchemaBuilder::object()
            .field("id", SchemaBuilder::int64())
            .field("name", SchemaBuilder::string().build())
            .optional_field("email", SchemaBuilder::string().build())
            .build();

        let output = gen.generate("Person", &schema).unwrap();
        assert!(output.contains("export interface Person"));
        assert!(output.contains("id: number"));
        assert!(output.contains("name: string"));
        assert!(output.contains("email?: string"));
    }

    #[test]
    fn test_generate_enum() {
        let gen = TypeScriptGenerator::new();
        let schema = SchemaBuilder::enum_values(vec!["Red", "Green", "Blue"]);

        let output = gen.generate("Color", &schema).unwrap();
        assert!(output.contains("export type Color"));
        assert!(output.contains("'Red'"));
        assert!(output.contains("'Green'"));
        assert!(output.contains("'Blue'"));
    }

    #[test]
    fn test_generate_module() {
        let gen = TypeScriptGenerator::new();
        let mut registry = SchemaRegistry::new();

        registry.register(
            "Person",
            SchemaBuilder::object()
                .field("id", SchemaBuilder::int64())
                .field("name", SchemaBuilder::string().build())
                .build(),
        );

        let output = gen.generate_module(&registry).unwrap();
        assert!(output.contains("// Auto-generated"));
        assert!(output.contains("export interface Person"));
    }

    #[test]
    fn test_generate_function_type() {
        let gen = TypeScriptGenerator::new();
        let schema = SchemaBuilder::function(
            vec![SchemaBuilder::int64(), SchemaBuilder::string().build()],
            SchemaBuilder::void(),
        );

        let output = gen.generate("Callback", &schema).unwrap();
        assert!(output.contains("export type Callback"));
        assert!(output.contains("=> void"));
    }
}