use crate::codegen::traits::file_writer::FileInfo;
use crate::ir::types::{
IrEnum, IrEnumValueType, IrIntersection, IrObject, IrPrimitive, IrProperty, IrSchema,
IrSchemaKind, IrSpec, IrTaggedUnion, IrTypeExpr, IrUnion, TaggingStyle,
};
use heck::{ToPascalCase, ToSnakeCase};
use sigil_stitch::prelude::{CodeBlock, sigil_quote};
use sigil_stitch::spec::field_spec::FieldSpec;
use sigil_stitch::spec::file_spec::FileSpec;
use sigil_stitch::spec::modifiers::TypeKind;
use sigil_stitch::spec::type_spec::TypeSpec;
use sigil_stitch::type_name::TypeName;
const MODELS_PACKAGE: &str = "models";
const RENDER_WIDTH: usize = 100;
pub fn generate_model_files(ir: &IrSpec, header: &str) -> Result<Vec<FileInfo>, String> {
let mut files = Vec::new();
for (name, schema) in &ir.schemas {
let Some(body) = emit_model_body(schema) else {
return Err(format!(
"unsupported schema kind for {name}: {:?}",
schema.kind
));
};
files.push(model_file(&schema.name, header, &body));
}
Ok(files)
}
fn model_file(name: &str, header: &str, body: &str) -> FileInfo {
let stem = name.to_snake_case();
let filename = if stem.ends_with("_test") {
format!("{stem}_model.go")
} else {
format!("{stem}.go")
};
let mut content = String::with_capacity(header.len() + body.len());
content.push_str(header);
content.push_str(body);
FileInfo::model(filename, content)
}
fn emit_model_body(schema: &IrSchema) -> Option<String> {
match &schema.kind {
IrSchemaKind::Object(obj) => emit_object(schema, obj),
IrSchemaKind::Enum(en) => emit_enum(schema, en),
IrSchemaKind::Alias(expr) => emit_alias(schema, expr),
IrSchemaKind::Union(u) => emit_union(schema, u),
IrSchemaKind::Intersection(i) => emit_intersection(schema, i),
IrSchemaKind::TaggedUnion(tu) => emit_tagged_union(schema, tu),
}
}
fn emit_object(schema: &IrSchema, obj: &IrObject) -> Option<String> {
let name = schema.name.to_pascal_case();
let mut tb = TypeSpec::builder(&name, TypeKind::Struct);
if let Some(doc) = &schema.description {
tb = tb.doc(doc);
}
for (json_name, prop) in &obj.properties {
tb = tb.add_field(build_struct_field(json_name, prop));
}
let fb = FileSpec::builder(&format!("{}.go", name))
.header(package_header())
.add_type(tb.build().ok()?);
let file = fb.build().ok()?;
file.render(RENDER_WIDTH).ok()
}
fn build_struct_field(json_name: &str, prop: &IrProperty) -> FieldSpec {
let field_name = go_field_name(&prop.name);
let ty = go_type_name(&prop.type_expr);
let tag = json_tag(json_name, prop.required, prop.nullable);
let mut fb = FieldSpec::builder(&field_name, ty).tag(&tag);
if !prop.required || prop.nullable {
fb = fb.is_optional();
}
if let Some(desc) = &prop.description {
fb = fb.doc(desc);
}
fb.build().expect("FieldSpec builds")
}
fn json_tag(json_name: &str, required: bool, nullable: bool) -> String {
if required && !nullable {
format!("json:\"{}\"", json_name)
} else {
format!("json:\"{},omitempty\"", json_name)
}
}
fn emit_enum(schema: &IrSchema, en: &IrEnum) -> Option<String> {
let name = schema.name.to_pascal_case();
let go_base = match en.value_type {
IrEnumValueType::String => "string",
IrEnumValueType::Integer => "int",
IrEnumValueType::Number => "float64",
IrEnumValueType::Mixed => {
return Some(render_alias_file(
&name,
"any",
schema.description.as_deref(),
));
}
};
let type_decl = format!("type {name} {go_base}");
let mut lines = Vec::with_capacity(en.values.len());
for v in &en.values {
let (const_name, rhs) = match en.value_type {
IrEnumValueType::String => {
let s = v.value.as_str()?;
(
format!("{name}{}", s.to_pascal_case()),
format!("\"{}\"", escape_go_string(s)),
)
}
IrEnumValueType::Integer | IrEnumValueType::Number => {
let n = v.value.as_number()?;
let pretty = n.to_string().replace(['-', '.'], "_");
(format!("{name}N{}", pretty), n.to_string())
}
IrEnumValueType::Mixed => unreachable!(),
};
lines.push(format!("\t{const_name} {name} = {rhs}"));
}
let body = format!(
"{}\n{}\nconst (\n{}\n)\n",
preamble(&name, schema.description.as_deref()),
type_decl,
lines.join("\n"),
);
Some(body)
}
fn emit_alias(schema: &IrSchema, expr: &IrTypeExpr) -> Option<String> {
let name = schema.name.to_pascal_case();
let rhs = go_type_str(expr);
Some(render_alias_file(
&name,
&rhs,
schema.description.as_deref(),
))
}
fn emit_union(schema: &IrSchema, _union: &IrUnion) -> Option<String> {
let name = schema.name.to_pascal_case();
Some(render_alias_file(
&name,
"any",
schema.description.as_deref(),
))
}
fn emit_intersection(schema: &IrSchema, inter: &IrIntersection) -> Option<String> {
let name = schema.name.to_pascal_case();
let mut out = preamble(&name, schema.description.as_deref());
out.push_str(&format!("type {name} struct {{\n"));
for member in &inter.members {
out.push_str(&format!("\t{}\n", go_type_str(member)));
}
out.push_str("}\n");
Some(out)
}
fn emit_tagged_union(schema: &IrSchema, tu: &IrTaggedUnion) -> Option<String> {
if tu.variants.is_empty() {
return None;
}
let name = schema.name.to_pascal_case();
let hint = match &tu.tagging {
TaggingStyle::Internal => {
format!("Discriminator: {} (internal).", tu.discriminator_field)
}
TaggingStyle::Adjacent { content_field } => format!(
"Discriminator: {} / content: {} (adjacent).",
tu.discriminator_field, content_field
),
TaggingStyle::External => "Discriminator: variant key (external).".to_string(),
};
let combined_doc = match &schema.description {
Some(desc) => format!("{desc}\n\n{hint}"),
None => hint,
};
Some(render_alias_file(&name, "any", Some(&combined_doc)))
}
fn package_header() -> CodeBlock {
sigil_quote!(GoLang {
package $L(MODELS_PACKAGE)
})
.expect("package header builds")
}
fn render_alias_file(name: &str, rhs: &str, doc: Option<&str>) -> String {
let mut out = String::new();
out.push_str(&format!("package {MODELS_PACKAGE}\n\n"));
if let Some(d) = doc {
for line in d.lines() {
out.push_str(&format!("// {line}\n"));
}
}
out.push_str(&format!("type {name} = {rhs}\n"));
out
}
fn preamble(_name: &str, doc: Option<&str>) -> String {
let mut out = format!("package {MODELS_PACKAGE}\n\n");
if let Some(d) = doc {
for line in d.lines() {
out.push_str(&format!("// {line}\n"));
}
}
out
}
fn go_type_name(expr: &IrTypeExpr) -> TypeName {
TypeName::primitive(&go_type_str(expr))
}
fn go_type_str(expr: &IrTypeExpr) -> String {
match expr {
IrTypeExpr::Named(name) => name.to_pascal_case(),
IrTypeExpr::Primitive(p) => go_primitive(p).to_string(),
IrTypeExpr::Array(inner) => format!("[]{}", go_type_str(inner)),
IrTypeExpr::Map(inner) => format!("map[string]{}", go_type_str(inner)),
IrTypeExpr::Nullable(inner) => format!("*{}", go_type_str(inner)),
IrTypeExpr::StringLiteral(_) | IrTypeExpr::StringEnum(_) => "string".to_string(),
IrTypeExpr::Union(_) => "any".to_string(),
IrTypeExpr::Any => "any".to_string(),
}
}
fn go_primitive(p: &IrPrimitive) -> &'static str {
match p {
IrPrimitive::String
| IrPrimitive::Date
| IrPrimitive::DateTime
| IrPrimitive::Uuid
| IrPrimitive::StringWithFormat(_) => "string",
IrPrimitive::Binary => "[]byte",
IrPrimitive::Integer => "int",
IrPrimitive::IntegerWithFormat(format) => match format.as_str() {
"int32" => "int32",
"int64" => "int64",
_ => "int",
},
IrPrimitive::Number => "float64",
IrPrimitive::NumberWithFormat(format) => match format.as_str() {
"float" => "float32",
_ => "float64",
},
IrPrimitive::Boolean => "bool",
}
}
fn go_field_name(name: &str) -> String {
name.to_pascal_case()
}
fn escape_go_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}