use crate::ir::{Module, Node, NodeId, SchemaField};
use crate::types::ActiveType;
use serde_json::{json, Value as JsonValue};
use std::collections::{HashMap, HashSet};
fn no_schema() -> String {
"document has no schema block".to_string()
}
fn schema_root(module: &Module) -> Result<NodeId, String> {
module.schema.ok_or_else(no_schema)
}
fn literal_int(module: &Module, id: Option<NodeId>) -> Option<i64> {
match id.and_then(|node| module.get(node)) {
Some(Node::Literal(crate::ir::Value::Int(i))) => Some(*i),
_ => None,
}
}
fn literal_float(module: &Module, id: Option<NodeId>) -> Option<f64> {
match id.and_then(|node| module.get(node)) {
Some(Node::Literal(crate::ir::Value::Float(f))) => Some(*f),
Some(Node::Literal(crate::ir::Value::Int(i))) => Some(*i as f64),
_ => None,
}
}
fn literal_string(module: &Module, id: Option<NodeId>) -> Option<String> {
match id.and_then(|node| module.get(node)) {
Some(Node::Literal(crate::ir::Value::String(s))) => Some(s.clone()),
_ => None,
}
}
fn literal_default(module: &Module, field: &SchemaField) -> Option<JsonValue> {
match field.default.and_then(|node| module.get(node)) {
Some(Node::Literal(value)) => Some(super::value_to_json(value)),
_ => None,
}
}
fn is_required(module: &Module, id: NodeId) -> bool {
match module.get(id) {
Some(Node::SchemaField { field }) => {
!matches!(field.typ, ActiveType::Null) && literal_default(module, field).is_none()
}
_ => true,
}
}
fn pascal_case(s: &str) -> String {
let mut out = String::new();
for part in s.split(['_', '-']) {
let mut chars = part.chars();
match chars.next() {
None => out.push('_'),
Some(first) => {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
}
if out.is_empty() {
return "Type".to_string();
}
if out.starts_with(|c: char| c.is_ascii_digit()) {
out.insert(0, '_');
}
out
}
fn sanitize_ident(s: &str, fallback: &str) -> String {
let clean: String = s
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
if clean.is_empty()
|| clean.starts_with(|c: char| c.is_ascii_digit())
|| !clean
.chars()
.all(|c: char| c.is_ascii_alphanumeric() || c == '_')
{
return fallback.to_string();
}
clean
}
fn nominal_id(module: &Module, mut id: NodeId) -> NodeId {
while let Some(Node::SchemaArray { element }) = module.get(id) {
id = *element;
}
id
}
pub fn schema_to_json_schema(module: &Module) -> Result<JsonValue, String> {
let root = schema_root(module)?;
Ok(json_schema_node(module, root))
}
fn json_schema_node(module: &Module, id: NodeId) -> JsonValue {
match module.get(id) {
Some(Node::SchemaTable { fields, closed }) => {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for (name, field) in fields {
properties.insert(name.clone(), json_schema_node(module, *field));
if is_required(module, *field) {
required.push(JsonValue::String(name.clone()));
}
}
let mut obj = serde_json::Map::new();
obj.insert("type".to_string(), JsonValue::String("object".to_string()));
obj.insert("properties".to_string(), JsonValue::Object(properties));
if !required.is_empty() {
obj.insert("required".to_string(), JsonValue::Array(required));
}
if *closed {
obj.insert("additionalProperties".to_string(), JsonValue::Bool(false));
}
JsonValue::Object(obj)
}
Some(Node::SchemaArray { element }) => {
json!({"type": "array", "items": json_schema_node(module, *element)})
}
Some(Node::SchemaField { field }) => json_schema_field(module, field),
_ => JsonValue::Bool(true),
}
}
fn json_schema_field(module: &Module, field: &SchemaField) -> JsonValue {
let mut obj = serde_json::Map::new();
match &field.typ {
ActiveType::Null => {
obj.insert("type".to_string(), JsonValue::String("null".to_string()));
}
ActiveType::Bool => {
obj.insert("type".to_string(), JsonValue::String("boolean".to_string()));
}
ActiveType::Int => {
obj.insert("type".to_string(), JsonValue::String("integer".to_string()));
if let Some(min) = literal_int(module, field.min) {
obj.insert("minimum".to_string(), JsonValue::from(min));
}
if let Some(max) = literal_int(module, field.max) {
obj.insert("maximum".to_string(), JsonValue::from(max));
}
}
ActiveType::Float => {
obj.insert("type".to_string(), JsonValue::String("number".to_string()));
if let Some(min) = literal_float(module, field.min) {
obj.insert("minimum".to_string(), JsonValue::from(min));
}
if let Some(max) = literal_float(module, field.max) {
obj.insert("maximum".to_string(), JsonValue::from(max));
}
}
ActiveType::String => {
obj.insert("type".to_string(), JsonValue::String("string".to_string()));
if let Some(min) = literal_int(module, field.min_len) {
obj.insert("minLength".to_string(), JsonValue::from(min));
}
if let Some(max) = literal_int(module, field.max_len) {
obj.insert("maxLength".to_string(), JsonValue::from(max));
}
if let Some(pattern) = literal_string(module, field.pattern) {
obj.insert("pattern".to_string(), JsonValue::String(pattern));
}
}
ActiveType::Duration => {
obj.insert("type".to_string(), JsonValue::String("object".to_string()));
obj.insert("properties".to_string(), json!({"ms": {"type": "integer"}}));
obj.insert("required".to_string(), json!(["ms"]));
obj.insert("additionalProperties".to_string(), JsonValue::Bool(false));
}
ActiveType::Bytes => {
obj.insert("type".to_string(), JsonValue::String("integer".to_string()));
}
ActiveType::Table { closed } => {
obj.insert("type".to_string(), JsonValue::String("object".to_string()));
if *closed {
obj.insert("additionalProperties".to_string(), JsonValue::Bool(false));
}
}
ActiveType::Enum(members) => {
obj.insert(
"enum".to_string(),
JsonValue::Array(members.iter().cloned().map(JsonValue::String).collect()),
);
}
ActiveType::Secret => {
obj.insert("type".to_string(), JsonValue::String("string".to_string()));
obj.insert(
"description".to_string(),
JsonValue::String("secret value, redacted as ***SECRET*** on export".to_string()),
);
}
ActiveType::Any => return JsonValue::Bool(true),
}
if let Some(default) = literal_default(module, field) {
obj.insert("default".to_string(), default);
}
if let Some(reason) = field.deprecated.as_deref() {
obj.insert("deprecated".to_string(), JsonValue::Bool(true));
if !reason.is_empty() {
obj.insert(
"description".to_string(),
JsonValue::String(reason.to_string()),
);
}
}
JsonValue::Object(obj)
}
pub fn schema_to_openapi(module: &Module, name: &str) -> Result<JsonValue, String> {
let schema = schema_to_json_schema(module)?;
let mut schemas = serde_json::Map::new();
schemas.insert(sanitize_ident(name, "Config"), schema);
let mut components = serde_json::Map::new();
components.insert("schemas".to_string(), JsonValue::Object(schemas));
let mut out = serde_json::Map::new();
out.insert(
"openapi".to_string(),
JsonValue::String("3.1.0".to_string()),
);
out.insert("components".to_string(), JsonValue::Object(components));
Ok(JsonValue::Object(out))
}
pub fn schema_to_typescript(module: &Module, root_name: &str) -> Result<String, String> {
let root = schema_root(module)?;
if !matches!(module.get(root), Some(Node::SchemaTable { .. })) {
return Err("schema block must be a table for code generation".to_string());
}
let mut gen = TsGen {
module,
out: String::new(),
names: HashMap::new(),
taken: HashSet::new(),
};
let root_name = gen.type_name(root, pascal_case(&sanitize_ident(root_name, "Config")));
gen.emit_table(&root_name, root);
Ok(gen.out)
}
struct TsGen<'a> {
module: &'a Module,
out: String,
names: HashMap<NodeId, String>,
taken: HashSet<String>,
}
impl TsGen<'_> {
fn type_name(&mut self, id: NodeId, base: String) -> String {
if let Some(name) = self.names.get(&id) {
return name.clone();
}
let mut name = base;
while !self.taken.insert(name.clone()) {
name.push('_');
}
self.names.insert(id, name.clone());
name
}
fn emit_table(&mut self, name: &str, id: NodeId) {
let Some(Node::SchemaTable { fields, .. }) = self.module.get(id) else {
return;
};
let fields: Vec<(String, NodeId)> = fields.iter().map(|(k, v)| (k.clone(), *v)).collect();
self.out.push_str(&format!("export interface {name} {{\n"));
for (key, field) in &fields {
let optional = !is_required(self.module, *field);
let nominal = nominal_id(self.module, *field);
let tname = self.type_name(nominal, format!("{name}{}", pascal_case(key)));
let typ = self.ts_type(&tname, *field);
let mark = if optional { "?" } else { "" };
if let Some(reason) = self.deprecated(*field) {
if reason.is_empty() {
self.out.push_str(" /** @deprecated */\n");
} else {
self.out
.push_str(&format!(" /** @deprecated {reason} */\n"));
}
}
self.out.push_str(&format!(" {key}{mark}: {typ};\n"));
}
self.out.push_str("}\n");
for (key, field) in &fields {
let nominal = nominal_id(self.module, *field);
let tname = self.type_name(nominal, format!("{name}{}", pascal_case(key)));
self.emit_nested(&tname, *field);
}
}
fn emit_nested(&mut self, name: &str, id: NodeId) {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => self.emit_table(name, id),
Some(Node::SchemaArray { element }) => self.emit_nested(name, *element),
_ => {}
}
}
fn ts_type(&mut self, hint: &str, id: NodeId) -> String {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => hint.to_string(),
Some(Node::SchemaArray { element }) => {
format!("{}[]", self.ts_inline(hint, *element))
}
Some(Node::SchemaField { field }) => self.ts_field(field),
_ => "unknown".to_string(),
}
}
fn ts_inline(&mut self, hint: &str, id: NodeId) -> String {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => hint.to_string(),
Some(Node::SchemaArray { element }) => {
format!("{}[]", self.ts_inline(hint, *element))
}
Some(Node::SchemaField { field }) => self.ts_field(field),
_ => "unknown".to_string(),
}
}
fn ts_field(&self, field: &SchemaField) -> String {
match &field.typ {
ActiveType::Null => "null".to_string(),
ActiveType::Bool => "boolean".to_string(),
ActiveType::Int | ActiveType::Float | ActiveType::Duration | ActiveType::Bytes => {
"number".to_string()
}
ActiveType::String | ActiveType::Secret => "string".to_string(),
ActiveType::Table { .. } => "Record<string, unknown>".to_string(),
ActiveType::Enum(members) => members
.iter()
.map(|m| format!("{m:?}"))
.collect::<Vec<_>>()
.join(" | "),
ActiveType::Any => "unknown".to_string(),
}
}
fn deprecated(&self, id: NodeId) -> Option<String> {
match self.module.get(id) {
Some(Node::SchemaField { field }) => field.deprecated.clone(),
_ => None,
}
}
}
pub fn schema_to_go(module: &Module, root_name: &str) -> Result<String, String> {
let root = schema_root(module)?;
if !matches!(module.get(root), Some(Node::SchemaTable { .. })) {
return Err("schema block must be a table for code generation".to_string());
}
let mut gen = GoGen {
module,
out: String::new(),
names: HashMap::new(),
taken: HashSet::new(),
};
let root_name = gen.type_name(root, pascal_case(&sanitize_ident(root_name, "Config")));
gen.emit_table(&root_name, root);
Ok(gen.out)
}
struct GoGen<'a> {
module: &'a Module,
out: String,
names: HashMap<NodeId, String>,
taken: HashSet<String>,
}
impl GoGen<'_> {
fn type_name(&mut self, id: NodeId, base: String) -> String {
if let Some(name) = self.names.get(&id) {
return name.clone();
}
let mut name = base;
while !self.taken.insert(name.clone()) {
name.push('_');
}
self.names.insert(id, name.clone());
name
}
fn emit_table(&mut self, name: &str, id: NodeId) {
let Some(Node::SchemaTable { fields, .. }) = self.module.get(id) else {
return;
};
let fields: Vec<(String, NodeId)> = fields.iter().map(|(k, v)| (k.clone(), *v)).collect();
self.out.push_str(&format!("type {name} struct {{\n"));
let mut taken = HashSet::new();
for (key, field) in &fields {
let mut field_name = pascal_case(key);
while !taken.insert(field_name.clone()) {
field_name.push('_');
}
let nominal = nominal_id(self.module, *field);
let tname = self.type_name(nominal, format!("{name}{}", pascal_case(key)));
let typ = self.go_type(&tname, *field);
if let Some(reason) = self.deprecated(*field) {
if reason.is_empty() {
self.out.push_str("\t// Deprecated: no reason given.\n");
} else {
self.out.push_str(&format!("\t// Deprecated: {reason}\n"));
}
}
if let Some(doc) = self.doc(*field) {
self.out.push_str(&format!("\t// {doc}\n"));
}
self.out
.push_str(&format!("\t{field_name} {typ} `json:\"{key}\"`\n"));
}
self.out.push_str("}\n");
for (key, field) in &fields {
let nominal = nominal_id(self.module, *field);
let tname = self.type_name(nominal, format!("{name}{}", pascal_case(key)));
self.emit_nested(&tname, *field);
}
}
fn emit_nested(&mut self, name: &str, id: NodeId) {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => self.emit_table(name, id),
Some(Node::SchemaArray { element }) => self.emit_nested(name, *element),
_ => {}
}
}
fn go_type(&mut self, hint: &str, id: NodeId) -> String {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => hint.to_string(),
Some(Node::SchemaArray { element }) => {
format!("[]{}", self.go_inline(hint, *element))
}
Some(Node::SchemaField { field }) => self.go_field(field),
_ => "interface{}".to_string(),
}
}
fn go_inline(&mut self, hint: &str, id: NodeId) -> String {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => hint.to_string(),
Some(Node::SchemaArray { element }) => {
format!("[]{}", self.go_inline(hint, *element))
}
Some(Node::SchemaField { field }) => self.go_field(field),
_ => "interface{}".to_string(),
}
}
fn go_field(&self, field: &SchemaField) -> String {
match &field.typ {
ActiveType::Null | ActiveType::Any => "interface{}".to_string(),
ActiveType::Bool => "bool".to_string(),
ActiveType::Int | ActiveType::Duration | ActiveType::Bytes => "int64".to_string(),
ActiveType::Float => "float64".to_string(),
ActiveType::String | ActiveType::Secret | ActiveType::Enum(_) => "string".to_string(),
ActiveType::Table { .. } => "map[string]interface{}".to_string(),
}
}
fn doc(&self, id: NodeId) -> Option<String> {
match self.module.get(id) {
Some(Node::SchemaField { field }) => match &field.typ {
ActiveType::Duration => Some("milliseconds".to_string()),
ActiveType::Enum(members) => Some(format!("one of: {}", members.join(", "))),
ActiveType::Secret => {
Some("secret value, redacted as ***SECRET*** on export".to_string())
}
_ => None,
},
_ => None,
}
}
fn deprecated(&self, id: NodeId) -> Option<String> {
match self.module.get(id) {
Some(Node::SchemaField { field }) => field.deprecated.clone(),
_ => None,
}
}
}
const RUST_KEYWORDS: &[&str] = &[
"as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for",
"if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
"self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where",
"while", "async", "await", "dyn", "abstract", "become", "box", "do", "final", "macro",
"override", "priv", "typeof", "unsized", "virtual", "yield", "try",
];
pub fn schema_to_rust(module: &Module, root_name: &str) -> Result<String, String> {
let root = schema_root(module)?;
if !matches!(module.get(root), Some(Node::SchemaTable { .. })) {
return Err("schema block must be a table for code generation".to_string());
}
let mut gen = RustGen {
module,
out: String::from(
"// Requires: serde = { version = \"1\", features = [\"derive\"] }, serde_json = \"1\"\nuse serde::{Deserialize, Serialize};\n\n",
),
names: HashMap::new(),
taken: HashSet::new(),
};
let root_name = gen.type_name(root, pascal_case(&sanitize_ident(root_name, "Config")));
gen.emit_table(&root_name, root);
Ok(gen.out)
}
struct RustGen<'a> {
module: &'a Module,
out: String,
names: HashMap<NodeId, String>,
taken: HashSet<String>,
}
impl RustGen<'_> {
fn type_name(&mut self, id: NodeId, base: String) -> String {
if let Some(name) = self.names.get(&id) {
return name.clone();
}
let mut name = base;
while !self.taken.insert(name.clone()) {
name.push('_');
}
self.names.insert(id, name.clone());
name
}
fn emit_table(&mut self, name: &str, id: NodeId) {
let Some(Node::SchemaTable { fields, .. }) = self.module.get(id) else {
return;
};
let fields: Vec<(String, NodeId)> = fields.iter().map(|(k, v)| (k.clone(), *v)).collect();
self.out
.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ");
self.out.push_str(name);
self.out.push_str(" {\n");
let mut taken = HashSet::new();
for (key, field) in &fields {
let optional = !is_required(self.module, *field);
let raw = sanitize_ident(key, "field");
let (field_name, mut renamed) = if RUST_KEYWORDS.contains(&raw.as_str()) {
(format!("r#{raw}"), true)
} else {
(raw.clone(), raw != *key)
};
let mut field_name = field_name;
while !taken.insert(field_name.clone()) {
field_name.push('_');
renamed = true;
}
let inner = {
let nominal = nominal_id(self.module, *field);
let tname = self.type_name(nominal, format!("{name}{}", pascal_case(key)));
self.rust_type(&tname, *field)
};
let typ = if optional {
format!("Option<{inner}>")
} else {
inner
};
if let Some(reason) = self.deprecated(*field) {
if reason.is_empty() {
self.out.push_str("\t#[deprecated]\n");
} else {
self.out
.push_str(&format!("\t#[deprecated(note = {reason:?})]\n"));
}
}
if renamed {
self.out
.push_str(&format!("\t#[serde(rename = {key:?})]\n"));
}
self.out.push_str(&format!("\tpub {field_name}: {typ},\n"));
}
self.out.push_str("}\n");
for (key, field) in &fields {
let nominal = nominal_id(self.module, *field);
let tname = self.type_name(nominal, format!("{name}{}", pascal_case(key)));
self.emit_nested(&tname, *field);
}
}
fn emit_nested(&mut self, name: &str, id: NodeId) {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => self.emit_table(name, id),
Some(Node::SchemaArray { element }) => self.emit_nested(name, *element),
Some(Node::SchemaField { field }) => {
if let ActiveType::Enum(members) = &field.typ {
self.emit_enum(name, members);
}
}
_ => {}
}
}
fn emit_enum(&mut self, name: &str, members: &[String]) {
self.out
.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\npub enum ");
self.out.push_str(name);
self.out.push_str(" {\n");
let mut taken = HashSet::new();
for member in members {
let mut variant = pascal_case(&sanitize_ident(member, "Value"));
while !taken.insert(variant.clone()) {
variant.push('_');
}
if &variant != member {
self.out
.push_str(&format!("\t#[serde(rename = {member:?})]\n"));
}
self.out.push_str(&format!("\t{variant},\n"));
}
self.out.push_str("}\n");
}
fn rust_type(&mut self, hint: &str, id: NodeId) -> String {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => hint.to_string(),
Some(Node::SchemaArray { element }) => {
format!("Vec<{}>", self.rust_inline(hint, *element))
}
Some(Node::SchemaField { field }) => match &field.typ {
ActiveType::Null => "()".to_string(),
ActiveType::Bool => "bool".to_string(),
ActiveType::Int => "i64".to_string(),
ActiveType::Float => "f64".to_string(),
ActiveType::String | ActiveType::Secret => "String".to_string(),
ActiveType::Duration | ActiveType::Bytes => "u64".to_string(),
ActiveType::Table { .. } => {
"std::collections::HashMap<String, serde_json::Value>".to_string()
}
ActiveType::Enum(_) => hint.to_string(),
ActiveType::Any => "serde_json::Value".to_string(),
},
_ => "serde_json::Value".to_string(),
}
}
fn rust_inline(&mut self, hint: &str, id: NodeId) -> String {
match self.module.get(id) {
Some(Node::SchemaTable { .. }) => hint.to_string(),
Some(Node::SchemaArray { element }) => {
format!("Vec<{}>", self.rust_inline(hint, *element))
}
Some(Node::SchemaField { field }) => match &field.typ {
ActiveType::Enum(_) => hint.to_string(),
_ => self.rust_type(hint, id),
},
_ => "serde_json::Value".to_string(),
}
}
fn deprecated(&self, id: NodeId) -> Option<String> {
match self.module.get(id) {
Some(Node::SchemaField { field }) => field.deprecated.clone(),
_ => None,
}
}
}