use std::fmt::Write as _;
use super::schema::{EnumDef, Field, GleamType, RecordDef, SchemaArtifact, TypeDef};
pub(crate) const GENERATED_HEADER: &str =
"//// Generated by aion codegen — do not edit; regenerate from schemas/.";
pub(crate) fn emit_module(project_name: &str, artifacts: &[SchemaArtifact]) -> String {
let has_optionals = artifacts.iter().any(|artifact| {
artifact.defs.iter().any(|def| match def {
TypeDef::Record(record) => record.fields.iter().any(|field| !field.required),
TypeDef::Enum(_) => false,
})
});
let mut out = String::new();
let _ = writeln!(out, "{GENERATED_HEADER}");
out.push_str("////\n");
let _ = writeln!(
out,
"//// Types and JSON codecs for the `{project_name}` workflow project,"
);
out.push_str("//// derived from every `schemas/*.json` document in filename order.\n");
out.push('\n');
out.push_str("import gleam/dynamic/decode\n");
out.push_str("import gleam/json\n");
if has_optionals {
out.push_str("import gleam/list\n");
out.push_str("import gleam/option\n");
}
for artifact in artifacts {
if !matches!(artifact.root, GleamType::Named { .. }) {
emit_root_wrapper(&mut out, artifact);
}
for def in &artifact.defs {
match def {
TypeDef::Record(record) => emit_record(&mut out, artifact, record),
TypeDef::Enum(definition) => emit_enum(&mut out, artifact, definition),
}
}
}
out
}
fn emit_root_wrapper(out: &mut String, artifact: &SchemaArtifact) {
let file = artifact.file.display();
let stem = &artifact.stem;
let ty = type_text(&artifact.root);
let _ = write!(
out,
"\n/// Encodes the `{file}` payload as schema-shaped JSON.\n\
pub fn {stem}_to_json(value: {ty}) -> json.Json {{\n {}\n}}\n",
encode_call(&artifact.root, "value", 0)
);
let _ = write!(
out,
"\n/// Decoder for the `{file}` payload.\n\
pub fn {stem}_decoder() -> decode.Decoder({ty}) {{\n {}\n}}\n",
decoder_expr(&artifact.root)
);
}
fn origin(artifact: &SchemaArtifact, pointer: &str) -> String {
if pointer.is_empty() {
format!("`{}`", artifact.file.display())
} else {
format!("`{}` at `{pointer}`", artifact.file.display())
}
}
fn emit_record(out: &mut String, artifact: &SchemaArtifact, record: &RecordDef) {
let name = &record.type_name;
let prefix = &record.fn_prefix;
let source = origin(artifact, &record.pointer);
let _ = write!(out, "\n/// Generated from {source}.\npub type {name} {{\n");
if record.fields.is_empty() {
let _ = writeln!(out, " {name}");
} else {
let _ = writeln!(out, " {name}(");
for field in &record.fields {
let _ = writeln!(out, " {}: {},", field.wire, field_type_text(field));
}
out.push_str(" )\n");
}
out.push_str("}\n");
let _ = write!(
out,
"\n/// Encodes `{name}` as schema-shaped JSON; optional fields are\n\
/// omitted when `None`.\n\
pub fn {prefix}_to_json(value: {name}) -> json.Json {{\n"
);
if record.fields.iter().all(|field| field.required) {
out.push_str(" json.object([\n");
for field in &record.fields {
let _ = writeln!(out, " {},", field_pair(field));
}
out.push_str(" ])\n");
} else {
out.push_str(" json.object(\n list.flatten([\n");
for field in &record.fields {
if field.required {
let _ = writeln!(out, " [{}],", field_pair(field));
} else {
let _ = writeln!(out, " case value.{} {{", field.wire);
let _ = writeln!(
out,
" option.Some(present) -> [#(\"{}\", {})]",
field.wire,
encode_call(&field.ty, "present", 0)
);
out.push_str(" option.None -> []\n },\n");
}
}
out.push_str(" ]),\n )\n");
}
out.push_str("}\n");
let _ = write!(
out,
"\n/// Decoder for `{name}` from schema-shaped JSON.\n\
pub fn {prefix}_decoder() -> decode.Decoder({name}) {{\n"
);
for field in &record.fields {
if field.required {
let _ = writeln!(
out,
" use field_{wire} <- decode.field(\"{wire}\", {})",
decoder_expr(&field.ty),
wire = field.wire,
);
} else {
let _ = writeln!(
out,
" use field_{wire} <- decode.optional_field(\n \"{wire}\",\n \
option.None,\n decode.optional({}),\n )",
decoder_expr(&field.ty),
wire = field.wire,
);
}
}
if record.fields.is_empty() {
let _ = writeln!(out, " decode.success({name})");
} else {
let _ = writeln!(out, " decode.success({name}(");
for field in &record.fields {
let _ = writeln!(out, " {wire}: field_{wire},", wire = field.wire);
}
out.push_str(" ))\n");
}
out.push_str("}\n");
}
fn emit_enum(out: &mut String, artifact: &SchemaArtifact, definition: &EnumDef) {
let Some(first_variant) = definition.variants.first() else {
return;
};
let name = &definition.type_name;
let prefix = &definition.fn_prefix;
let source = origin(artifact, &definition.pointer);
let _ = write!(out, "\n/// Generated from {source}.\npub type {name} {{\n");
for variant in &definition.variants {
let _ = writeln!(out, " {}", variant.constructor);
}
out.push_str("}\n");
let _ = write!(
out,
"\n/// Encodes `{name}` as its wire string.\n\
pub fn {prefix}_to_json(value: {name}) -> json.Json {{\n case value {{\n"
);
for variant in &definition.variants {
let _ = writeln!(
out,
" {} -> json.string(\"{}\")",
variant.constructor, variant.wire
);
}
out.push_str(" }\n}\n");
let _ = write!(
out,
"\n/// Decoder for `{name}` from its wire string.\n\
pub fn {prefix}_decoder() -> decode.Decoder({name}) {{\n \
decode.then(decode.string, fn(raw) {{\n case raw {{\n"
);
for variant in &definition.variants {
let _ = writeln!(
out,
" \"{}\" -> decode.success({})",
variant.wire, variant.constructor
);
}
let _ = writeln!(
out,
" _ -> decode.failure({}, \"{name}\")",
first_variant.constructor
);
out.push_str(" }\n })\n}\n");
}
fn field_pair(field: &Field) -> String {
format!(
"#(\"{wire}\", {})",
encode_call(&field.ty, &format!("value.{}", field.wire), 0),
wire = field.wire,
)
}
fn field_type_text(field: &Field) -> String {
let inner = type_text(&field.ty);
if field.required {
inner
} else {
format!("option.Option({inner})")
}
}
fn type_text(ty: &GleamType) -> String {
match ty {
GleamType::String => "String".to_owned(),
GleamType::Int => "Int".to_owned(),
GleamType::Float => "Float".to_owned(),
GleamType::Bool => "Bool".to_owned(),
GleamType::List(inner) => format!("List({})", type_text(inner)),
GleamType::Named { type_name, .. } => type_name.clone(),
}
}
fn encode_call(ty: &GleamType, expr: &str, depth: usize) -> String {
match ty {
GleamType::String => format!("json.string({expr})"),
GleamType::Int => format!("json.int({expr})"),
GleamType::Float => format!("json.float({expr})"),
GleamType::Bool => format!("json.bool({expr})"),
GleamType::List(inner) => format!("json.array({expr}, {})", encode_fn(inner, depth)),
GleamType::Named { fn_prefix, .. } => format!("{fn_prefix}_to_json({expr})"),
}
}
fn encode_fn(ty: &GleamType, depth: usize) -> String {
match ty {
GleamType::String => "json.string".to_owned(),
GleamType::Int => "json.int".to_owned(),
GleamType::Float => "json.float".to_owned(),
GleamType::Bool => "json.bool".to_owned(),
GleamType::List(inner) => {
let var = format!("items{depth}");
format!(
"fn({var}) {{ json.array({var}, {}) }}",
encode_fn(inner, depth + 1)
)
}
GleamType::Named { fn_prefix, .. } => format!("{fn_prefix}_to_json"),
}
}
fn decoder_expr(ty: &GleamType) -> String {
match ty {
GleamType::String => "decode.string".to_owned(),
GleamType::Int => "decode.int".to_owned(),
GleamType::Float => "decode.float".to_owned(),
GleamType::Bool => "decode.bool".to_owned(),
GleamType::List(inner) => format!("decode.list({})", decoder_expr(inner)),
GleamType::Named { fn_prefix, .. } => format!("{fn_prefix}_decoder()"),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{GENERATED_HEADER, emit_module};
use crate::codegen::json::parse_ordered;
use crate::codegen::names::NameRegistry;
use crate::codegen::schema::{SchemaArtifact, parse_schema};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn artifact(stem: &str, json: &str, registry: &mut NameRegistry) -> TestResult2 {
let document = parse_ordered(json.as_bytes())?;
Ok(parse_schema(
Path::new(&format!("schemas/{stem}.json")),
stem,
&document,
registry,
)?)
}
type TestResult2 = Result<SchemaArtifact, Box<dyn std::error::Error>>;
#[test]
fn full_module_golden_with_enum_array_and_optional() -> TestResult {
let mut registry = NameRegistry::default();
let artifacts = vec![artifact(
"event",
r#"{
"type": "object",
"required": ["kind", "tags"],
"additionalProperties": false,
"properties": {
"kind": { "type": "string", "enum": ["created", "closed_out"] },
"tags": { "type": "array", "items": { "type": "string" } },
"note": { "type": "string" }
}
}"#,
&mut registry,
)?];
let expected = r#"//// Generated by aion codegen — do not edit; regenerate from schemas/.
////
//// Types and JSON codecs for the `demo` workflow project,
//// derived from every `schemas/*.json` document in filename order.
import gleam/dynamic/decode
import gleam/json
import gleam/list
import gleam/option
/// Generated from `schemas/event.json`.
pub type Event {
Event(
kind: EventKind,
tags: List(String),
note: option.Option(String),
)
}
/// Encodes `Event` as schema-shaped JSON; optional fields are
/// omitted when `None`.
pub fn event_to_json(value: Event) -> json.Json {
json.object(
list.flatten([
[#("kind", event_kind_to_json(value.kind))],
[#("tags", json.array(value.tags, json.string))],
case value.note {
option.Some(present) -> [#("note", json.string(present))]
option.None -> []
},
]),
)
}
/// Decoder for `Event` from schema-shaped JSON.
pub fn event_decoder() -> decode.Decoder(Event) {
use field_kind <- decode.field("kind", event_kind_decoder())
use field_tags <- decode.field("tags", decode.list(decode.string))
use field_note <- decode.optional_field(
"note",
option.None,
decode.optional(decode.string),
)
decode.success(Event(
kind: field_kind,
tags: field_tags,
note: field_note,
))
}
/// Generated from `schemas/event.json` at `/properties/kind`.
pub type EventKind {
EventKindCreated
EventKindClosedOut
}
/// Encodes `EventKind` as its wire string.
pub fn event_kind_to_json(value: EventKind) -> json.Json {
case value {
EventKindCreated -> json.string("created")
EventKindClosedOut -> json.string("closed_out")
}
}
/// Decoder for `EventKind` from its wire string.
pub fn event_kind_decoder() -> decode.Decoder(EventKind) {
decode.then(decode.string, fn(raw) {
case raw {
"created" -> decode.success(EventKindCreated)
"closed_out" -> decode.success(EventKindClosedOut)
_ -> decode.failure(EventKindCreated, "EventKind")
}
})
}
"#;
assert_eq!(emit_module("demo", &artifacts), expected);
Ok(())
}
#[test]
fn scalar_root_emits_payload_wrappers_without_optional_imports() -> TestResult {
let mut registry = NameRegistry::default();
let artifacts = vec![artifact(
"output",
r#"{ "type": "string" }"#,
&mut registry,
)?];
let module = emit_module("demo", &artifacts);
assert!(module.starts_with(GENERATED_HEADER));
assert!(!module.contains("import gleam/list"));
assert!(!module.contains("import gleam/option"));
assert!(module.contains(
"/// Encodes the `schemas/output.json` payload as schema-shaped JSON.\n\
pub fn output_to_json(value: String) -> json.Json {\n json.string(value)\n}\n"
));
assert!(module.contains(
"/// Decoder for the `schemas/output.json` payload.\n\
pub fn output_decoder() -> decode.Decoder(String) {\n decode.string\n}\n"
));
Ok(())
}
#[test]
fn all_required_records_use_plain_object_lists() -> TestResult {
let mut registry = NameRegistry::default();
let artifacts = vec![artifact(
"pair",
r#"{
"type": "object",
"required": ["count", "ratio", "flag"],
"properties": {
"count": { "type": "integer" },
"ratio": { "type": "number" },
"flag": { "type": "boolean" }
}
}"#,
&mut registry,
)?];
let module = emit_module("demo", &artifacts);
assert!(module.contains(
"pub fn pair_to_json(value: Pair) -> json.Json {\n json.object([\n \
#(\"count\", json.int(value.count)),\n \
#(\"ratio\", json.float(value.ratio)),\n \
#(\"flag\", json.bool(value.flag)),\n ])\n}\n"
));
assert!(module.contains("use field_count <- decode.field(\"count\", decode.int)\n"));
assert!(!module.contains("list.flatten"));
Ok(())
}
#[test]
fn nested_lists_encode_with_depth_named_lambdas() -> TestResult {
let mut registry = NameRegistry::default();
let artifacts = vec![artifact(
"grid",
r#"{
"type": "object",
"required": ["matrix"],
"properties": {
"matrix": {
"type": "array",
"items": { "type": "array", "items": { "type": "integer" } }
}
}
}"#,
&mut registry,
)?];
let module = emit_module("demo", &artifacts);
assert!(module.contains(
"#(\"matrix\", json.array(value.matrix, fn(items0) { json.array(items0, json.int) }))"
));
assert!(module.contains(
"use field_matrix <- decode.field(\"matrix\", decode.list(decode.list(decode.int)))"
));
assert!(module.contains("matrix: List(List(Int)),"));
Ok(())
}
#[test]
fn empty_records_emit_bare_constructors() -> TestResult {
let mut registry = NameRegistry::default();
let artifacts = vec![artifact(
"blank",
r#"{ "type": "object", "required": [], "properties": {} }"#,
&mut registry,
)?];
let module = emit_module("demo", &artifacts);
assert!(module.contains("pub type Blank {\n Blank\n}\n"));
assert!(module.contains("json.object([\n ])"));
assert!(module.contains("decode.success(Blank)\n"));
Ok(())
}
#[test]
fn emission_is_deterministic() -> TestResult {
let schema = r#"{
"type": "object",
"required": ["kind"],
"properties": {
"kind": { "enum": ["a", "b"] },
"note": { "type": "string" }
}
}"#;
let mut first_registry = NameRegistry::default();
let first = emit_module("demo", &[artifact("event", schema, &mut first_registry)?]);
let mut second_registry = NameRegistry::default();
let second = emit_module("demo", &[artifact("event", schema, &mut second_registry)?]);
assert_eq!(first, second);
Ok(())
}
}