Current section
Files
Jump to
Current section
Files
src/libero/json/codegen.gleam
//// Typed JSON encoder and decoder codegen.
////
//// Generates Gleam source code for per-type `json.Json` builders and
//// typed decoders. Each discovered type gets a pair of functions:
//// `json_encode_<qualified_atom_name>` — builds a `json.Json` value
//// `json_decode_<qualified_atom_name>` — validates and decodes from `Dynamic`
////
//// The generated wire format follows the JSON-transport-v1 contract shape:
//// { "type": "<module>.<type_name>",
//// "variant": "<variant_name>",
//// "fields": <object or array> }
////
//// Labelled variants encode fields as a JSON object under "fields";
//// unlabelled variants encode them as a JSON array. Mixed labelled /
//// unlabelled constructors are rejected at codegen time.
import gleam/dict.{type Dict}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import libero/codegen
import libero/field_type.{
type FieldType, BitArrayField, BoolField, DictOf, FloatField, IntField, ListOf,
NilField, OptionOf, ResultOf, StringField, TupleOf, TypeVar, UserType,
}
import libero/json/contract
import libero/json/error.{type JsonError, JsonError}
import libero/walker.{type DiscoveredType, type DiscoveredVariant}
const client_context_module = "__ClientContext__"
/// Generate Gleam source for JSON typed encoders and decoders.
/// Returns `Ok(source)` on success, or `Error(List(JsonError))` if any
/// constructor has mixed labelled/unlabelled fields (rejected for JSON v1).
pub fn generate(
discovered: List(DiscoveredType),
) -> Result(String, List(JsonError)) {
generate_with_extra(
discovered:,
extra_fields: [],
extra_tail: "",
needs_json_wire: False,
needs_frame: False,
)
}
fn generate_with_extra(
discovered discovered: List(DiscoveredType),
extra_fields extra_fields: List(FieldType),
extra_tail extra_tail: String,
needs_json_wire needs_json_wire: Bool,
needs_frame needs_frame: Bool,
) -> Result(String, List(JsonError)) {
use _ <- result.try(check_no_mixed_fields(discovered))
use _ <- result.try(check_supported_field_types(discovered))
let aliases =
discovered
|> list.map(fn(dt) { dt.module_path })
|> codegen.build_module_alias_map
let user_imports =
aliases
|> dict.to_list
|> list.filter(fn(pair) {
let #(mod, _) = pair
!string.starts_with(mod, "gleam/")
&& !string.starts_with(mod, "libero/")
&& mod != "gleam"
})
|> list.map(fn(pair) {
let #(mod, alias) = pair
let seg = field_type.last_segment(mod)
case alias == seg {
True -> "import " <> mod
False -> "import " <> mod <> " as " <> alias
}
})
|> string.join("\n")
|> fn(s) {
case s {
"" -> ""
_ -> s <> "\n"
}
}
let all_fields = list.append(collect_all_fields(discovered), extra_fields)
let needs_bit_array = list.any(all_fields, fn(ft) { ft == BitArrayField })
let has_dict_field = list.any(all_fields, uses_dict)
let needs_dict = has_dict_field
let has_list_field = list.any(all_fields, uses_list)
let needs_list = has_dict_field || has_list_field
let bit_array_import = case needs_bit_array {
True -> "import gleam/bit_array\n"
False -> ""
}
let dict_import = case needs_dict {
True -> "import gleam/dict\n"
False -> ""
}
let int_import = "import gleam/int\n"
let list_import = case needs_list {
True -> "import gleam/list\n"
False -> ""
}
let list_at_helper = ""
let json_wire_import = case needs_json_wire {
True -> "import libero/json/wire as json_wire\n"
False -> ""
}
let frame_import = case needs_frame {
True -> "import libero/frame\n"
False -> ""
}
let header =
"// Generated by libero. DO NOT EDIT.\n"
<> "////\n"
<> "//// Typed JSON encoders and decoders for all discovered types.\n"
<> "\n"
<> "import gleam/dynamic.{type Dynamic}\n"
<> "import gleam/json\n"
<> bit_array_import
<> frame_import
<> json_wire_import
<> "import libero/json/runtime as json_runtime\n"
<> "import libero/json/error.{type JsonError, JsonError}\n"
<> "import gleam/result\n"
<> "import gleam/option.{type Option, None, Some}\n"
<> int_import
<> list_import
<> dict_import
<> user_imports
<> "\n"
<> list_at_helper
<> "\n"
let encoders = list.map(discovered, fn(dt) { emit_type_encoder(dt, aliases) })
let decoders = list.map(discovered, fn(dt) { emit_type_decoder(dt, aliases) })
let builtin_encoders =
emit_option_encoder() <> "\n\n" <> emit_result_encoder()
let builtin_decoders =
emit_option_decoder() <> "\n\n" <> emit_result_decoder()
Ok(
header
<> string.join(encoders, "\n\n")
<> "\n\n"
<> string.join(decoders, "\n\n")
<> "\n\n"
<> builtin_encoders
<> "\n\n"
<> builtin_decoders
<> extra_tail
<> "\n",
)
}
/// Generate JSON codecs and transport wrappers for transport, push, and SSR.
pub fn generate_transport_codecs_with_push_and_ssr(
discovered discovered: List(DiscoveredType),
push_types push_types: List(contract.PushContract),
ssr_models ssr_models: List(contract.SsrModelContract),
) -> Result(String, List(JsonError)) {
let transport_helpers =
[
emit_push_helpers(push_types),
emit_client_context_helpers(push_types),
emit_ssr_helpers(ssr_models),
]
|> list.filter(fn(src) { src != "" })
|> string.join("\n\n")
|> fn(src) {
case src {
"" -> ""
_ -> "\n\n" <> src
}
}
generate_with_extra(
discovered: discovered,
extra_fields: [],
extra_tail: transport_helpers,
needs_json_wire: !list.is_empty(push_types) || !list.is_empty(ssr_models),
needs_frame: has_client_context_push(push_types),
)
}
fn has_client_context_push(push_types: List(contract.PushContract)) -> Bool {
list.any(push_types, fn(push) { push.module == client_context_module })
}
fn emit_push_helpers(push_types: List(contract.PushContract)) -> String {
push_types
|> list.map(fn(push) {
let qual =
walker.qualified_atom_name(
module_path: push.type_module,
variant_name: push.type_name,
)
"pub fn json_encode_push_"
<> qual
<> "(module module: String, value value) -> String {\n"
<> " json_wire.encode_push(module:, value: json_encode_"
<> qual
<> "(value))\n"
<> "}"
})
|> list.unique
|> string.join("\n\n")
}
fn emit_client_context_helpers(
push_types: List(contract.PushContract),
) -> String {
push_types
|> list.filter(fn(push) { push.module == client_context_module })
|> list.flat_map(fn(push) {
let qual =
walker.qualified_atom_name(
module_path: push.type_module,
variant_name: push.type_name,
)
[
"pub fn json_encode_client_context_"
<> qual
<> "(value value) -> String {\n"
<> " json_wire.encode_push(module: \""
<> client_context_module
<> "\", value: json_encode_"
<> qual
<> "(value))\n"
<> "}",
"pub fn json_decode_client_context_"
<> qual
<> "(data data: String) {\n"
<> " case json_wire.decode_server_frame(data) {\n"
<> " Ok(frame.Push(module:, value: raw)) -> case module {\n"
<> " \""
<> client_context_module
<> "\" -> json_decode_"
<> qual
<> "(raw)\n"
<> " other -> Error([JsonError(\"module\", \"expected "
<> client_context_module
<> ", got \" <> other)])\n"
<> " }\n"
<> " Ok(_) -> Error([JsonError(\"kind\", \"expected client-context push frame\")])\n"
<> " Error(errors) -> Error(errors)\n"
<> " }\n"
<> "}",
]
})
|> list.unique
|> string.join("\n\n")
}
fn emit_ssr_helpers(ssr_models: List(contract.SsrModelContract)) -> String {
ssr_models
|> list.flat_map(fn(model) {
let qual =
walker.qualified_atom_name(
module_path: model.type_module,
variant_name: model.type_name,
)
[
"pub fn json_encode_ssr_"
<> qual
<> "(value value) -> String {\n"
<> " json_wire.encode_flags(json_encode_"
<> qual
<> "(value))\n"
<> "}",
"pub fn json_decode_ssr_"
<> qual
<> "(flags flags: String) {\n"
<> " json_wire.decode_flags_typed(flags:, decoder: json_decode_"
<> qual
<> ")\n"
<> "}",
]
})
|> list.unique
|> string.join("\n\n")
}
fn check_no_mixed_fields(
discovered: List(DiscoveredType),
) -> Result(Nil, List(JsonError)) {
let errors =
list.flat_map(discovered, fn(dt) {
list.flat_map(dt.variants, fn(v) {
case has_mixed_fields(v.field_labels) {
True -> [
JsonError(
path: dt.module_path
<> "."
<> dt.type_name
<> "."
<> v.variant_name,
message: "mixed labelled/unlabelled fields are not supported in JSON v1",
),
]
False -> []
}
})
})
case errors {
[] -> Ok(Nil)
_ -> Error(errors)
}
}
fn has_mixed_fields(labels: List(Option(String))) -> Bool {
let has_labelled = list.any(labels, fn(l) { l != None })
let has_unlabelled = list.any(labels, fn(l) { l == None })
has_labelled && has_unlabelled
}
fn check_supported_field_types(
discovered: List(DiscoveredType),
) -> Result(Nil, List(JsonError)) {
let errors =
list.flat_map(discovered, fn(dt) {
list.flat_map(dt.variants, fn(v) {
v.fields
|> list.index_map(fn(field, index) { #(field, index) })
|> list.flat_map(fn(pair) {
let #(field, index) = pair
check_supported_field_type(
field,
dt.module_path
<> "."
<> dt.type_name
<> "."
<> v.variant_name
<> ".field["
<> int.to_string(index)
<> "]",
)
})
})
})
case errors {
[] -> Ok(Nil)
_ -> Error(errors)
}
}
fn check_supported_field_type(ft: FieldType, path: String) -> List(JsonError) {
case ft {
IntField
| FloatField
| StringField
| BoolField
| BitArrayField
| NilField -> []
TypeVar(name:) -> [
JsonError(
path: path,
message: "type variable " <> name <> " cannot cross the JSON wire",
),
]
UserType(args:, ..) ->
args
|> list.index_map(fn(arg, index) { #(arg, index) })
|> list.flat_map(fn(pair) {
let #(arg, index) = pair
check_supported_field_type(
arg,
path <> ".arg[" <> int.to_string(index) <> "]",
)
})
ListOf(element:) -> check_supported_field_type(element, path <> ".element")
OptionOf(inner:) -> check_supported_field_type(inner, path <> ".inner")
ResultOf(ok:, err:) ->
list.append(
check_supported_field_type(ok, path <> ".ok"),
check_supported_field_type(err, path <> ".err"),
)
DictOf(key:, value:) -> {
let key_errors = case key {
IntField | StringField | BoolField -> []
_ -> [
JsonError(
path: path,
message: "Dict key type "
<> field_type.to_canonical_token(key)
<> " is not supported by JSON transport; use Int, String, or Bool keys",
),
]
}
list.append(
key_errors,
check_supported_field_type(value, path <> ".value"),
)
}
TupleOf(elements:) ->
elements
|> list.index_map(fn(element, index) { #(element, index) })
|> list.flat_map(fn(pair) {
let #(element, index) = pair
check_supported_field_type(
element,
path <> ".element[" <> int.to_string(index) <> "]",
)
})
}
}
/// Validate that an Int value is within JavaScript safe integer range.
/// Panics with a descriptive message if the value is outside the range.
fn safe_int_check(var: String) -> String {
"case "
<> var
<> " >= -9007199254740991 && "
<> var
<> " <= 9007199254740991 {\n"
<> " True -> "
<> var
<> "\n"
<> " False -> panic as \"Int outside JavaScript safe integer range\"\n"
<> " }"
}
/// Validate that a Float value is finite (not NaN or Infinity).
/// NaN/Infinity can arise from FFI on the JavaScript runtime.
/// Panics with a descriptive message if the value is not finite.
fn finite_float_check(var: String) -> String {
"case "
<> var
<> " -. "
<> var
<> " == 0.0 {\n"
<> " True -> "
<> var
<> "\n"
<> " False -> panic as \"Float must be finite (not NaN or Infinity)\"\n"
<> " }"
}
/// Wrap a raw Gleam value variable with the appropriate `json.X()` constructor
/// so it becomes a `json.Json` expression.
fn json_encode_expr(ft: FieldType, var: String) -> String {
case ft {
StringField -> "json.string(" <> var <> ")"
IntField -> "json.int(" <> safe_int_check(var) <> ")"
FloatField -> "json.float(" <> finite_float_check(var) <> ")"
BoolField -> "json.bool(" <> var <> ")"
NilField -> "json.null()"
BitArrayField ->
"json.object([#(\"encoding\", json.string(\"base64url\")), #(\"data\", json.string(bit_array.base64_url_encode("
<> var
<> ", True)))])"
UserType(module_path:, type_name:, ..) -> {
let qual =
walker.qualified_atom_name(
module_path: module_path,
variant_name: type_name,
)
"json_encode_" <> qual <> "(" <> var <> ")"
}
ListOf(element) ->
"json.array("
<> var
<> ", of: fn(x) { "
<> json_encode_expr(element, "x")
<> " })"
OptionOf(inner) -> {
let qual = walker.qualified_atom_name("gleam/option", "Option")
"json_encode_"
<> qual
<> "("
<> var
<> ", fn(x) { "
<> json_encode_expr(inner, "x")
<> " })"
}
ResultOf(ok, err) -> {
let qual = walker.qualified_atom_name("gleam/result", "Result")
"json_encode_"
<> qual
<> "("
<> var
<> ", fn(x) { "
<> json_encode_expr(ok, "x")
<> " }, fn(x) { "
<> json_encode_expr(err, "x")
<> " })"
}
DictOf(key, value) -> {
case key {
StringField ->
"json.object(dict.to_list("
<> var
<> ") |> list.map(fn(kv) { let #(k, v) = kv #(k, "
<> json_encode_expr(value, "v")
<> ") }))"
_ ->
"json.array(dict.to_list("
<> var
<> "), of: fn(kv) { let #(k, v) = kv json.array(["
<> json_encode_expr(key, "k")
<> ", "
<> json_encode_expr(value, "v")
<> "], of: fn(x) { x }) })"
}
}
TupleOf(elements) -> {
let encoded =
list.index_map(elements, fn(e, i) {
json_encode_expr(e, var <> "." <> int.to_string(i))
})
"json.array([" <> string.join(encoded, ", ") <> "], of: fn(x) { x })"
}
TypeVar(name) -> "panic as \"cannot encode type variable " <> name <> "\""
}
}
/// Emit a per-type encoder function that pattern-matches on the type's
/// variants and builds a `json.Json` value with the standard wire shape:
/// { "type": "<module>.<type_name>",
/// "variant": "<variant_name>",
/// "fields": <per-variant encoding> }
fn emit_type_encoder(
dt: DiscoveredType,
aliases: Dict(String, String),
) -> String {
let qual =
walker.qualified_atom_name(
module_path: dt.module_path,
variant_name: dt.type_name,
)
let alias =
result.unwrap(
dict.get(aliases, dt.module_path),
field_type.last_segment(dt.module_path),
)
let clauses =
list.map(dt.variants, fn(v) {
emit_encode_clause(v: v, alias: alias, type_name: dt.type_name)
})
"pub fn json_encode_"
<> qual
<> "(value) -> json.Json {\n"
<> " case value {\n"
<> string.join(clauses, "\n")
<> "\n }\n}"
}
/// Emit a single encode pattern-match arm for one variant.
fn emit_encode_clause(
v variant: DiscoveredVariant,
alias alias: String,
type_name type_name: String,
) -> String {
let field_vars =
list.index_map(variant.fields, fn(field, i) {
case field {
NilField -> "_f" <> int.to_string(i)
_ -> "f" <> int.to_string(i)
}
})
let fields_expr = emit_fields_value(variant, field_vars)
let type_str = variant.module_path <> "." <> type_name
let pattern = case field_vars {
[] -> alias <> "." <> variant.variant_name
_ ->
alias
<> "."
<> variant.variant_name
<> "("
<> string.join(field_vars, ", ")
<> ")"
}
" "
<> pattern
<> " ->\n"
<> " json.object([\n"
<> " #(\"type\", json.string(\""
<> type_str
<> "\")),\n"
<> " #(\"variant\", json.string(\""
<> variant.variant_name
<> "\")),\n"
<> " #(\"fields\", "
<> fields_expr
<> "),\n"
<> " ])"
}
/// Build the "fields" value for a variant.
/// - Zero-field variants: `json.object([])`
/// - Unlabelled fields: `json.array([f0, f1, ...], of: fn(x) { x })`
/// - Labelled fields: `json.object([#("label", f0), ...])`
fn emit_fields_value(v: DiscoveredVariant, field_vars: List(String)) -> String {
case v.field_labels {
[] -> "json.object([])"
_ -> {
let all_unlabelled = list.all(v.field_labels, fn(l) { l == None })
case all_unlabelled {
True -> emit_unlabelled_fields_array(v, field_vars)
False -> emit_fields_object(v, field_vars)
}
}
}
}
fn emit_unlabelled_fields_array(
v: DiscoveredVariant,
field_vars: List(String),
) -> String {
let encoded =
list.zip(v.fields, field_vars)
|> list.map(fn(pair) {
let #(ft, fvar) = pair
json_encode_expr(ft, fvar)
})
"json.array([" <> string.join(encoded, ", ") <> "], of: fn(x) { x })"
}
fn emit_fields_object(
v: DiscoveredVariant,
field_vars: List(String),
) -> String {
let entries =
list.index_map(
list.zip(v.fields, list.zip(v.field_labels, field_vars)),
fn(pair, i) {
let #(ft, label_and_var) = pair
let #(label, fvar) = label_and_var
case label {
Some(name) ->
"#(\"" <> name <> "\", " <> json_encode_expr(ft, fvar) <> ")"
None ->
"#(\"_"
<> int.to_string(i)
<> "\", "
<> json_encode_expr(ft, fvar)
<> ")"
}
},
)
"json.object([" <> string.join(entries, ", ") <> "])"
}
/// Emit a per-type decoder function that validates the wire format and
/// dispatches to per-variant decoding.
fn emit_type_decoder(
dt: DiscoveredType,
aliases: Dict(String, String),
) -> String {
let qual =
walker.qualified_atom_name(
module_path: dt.module_path,
variant_name: dt.type_name,
)
let alias =
result.unwrap(
dict.get(aliases, dt.module_path),
field_type.last_segment(dt.module_path),
)
let clauses = list.map(dt.variants, fn(v) { emit_decode_clause(v, alias) })
let type_str = dt.module_path <> "." <> dt.type_name
"pub fn json_decode_"
<> qual
<> "(value: dynamic.Dynamic) {\n"
<> " use _ <- result.try(\n"
<> " case json_runtime.field_string(value, \"type\", \"type\") {\n"
<> " Ok(s) if s == \""
<> type_str
<> "\" -> Ok(s)\n"
<> " Ok(s) -> Error([JsonError(\"type\", \"expected "
<> type_str
<> ", got \" <> s)])\n"
<> " Error(_) -> Error([JsonError(\"type\", \"missing or not a string\")])\n"
<> " }\n"
<> " )\n"
<> " case json_runtime.field_string(value, \"variant\", \"variant\") {\n"
<> string.join(clauses, "\n")
<> " Error(_) -> Error([JsonError(\"variant\", \"missing or not a string\")])\n"
<> " Ok(s) -> Error([JsonError(\"variant\", \"unknown: \" <> s)])\n"
<> " }\n"
<> "}"
}
/// Emit a single decode clause that matches a variant name string and
/// decodes the variant's fields, constructing the Gleam value.
fn emit_decode_clause(v: DiscoveredVariant, alias: String) -> String {
let pad = " "
case v.fields {
[] ->
pad
<> "Ok(\""
<> v.variant_name
<> "\") -> {\n"
<> emit_fields_extract(pad <> " ")
<> emit_fields_object_size_check(
path: "fields",
expected_size: 0,
pad: pad <> " ",
)
<> pad
<> " Ok("
<> alias
<> "."
<> v.variant_name
<> ")\n"
<> pad
<> "}\n"
_ -> {
let all_labelled = list.all(v.field_labels, fn(l) { l != None })
let fields_extract = emit_fields_extract(pad <> " ")
let field_decodes = case all_labelled {
True ->
emit_fields_object_size_check(
path: "fields",
expected_size: list.length(v.fields),
pad: pad <> " ",
)
<> emit_labelled_field_decodes(v, pad <> " ")
False -> emit_unlabelled_field_decodes(v, pad <> " ")
}
let field_names = case all_labelled {
True -> emit_variant_constructor_labels(v)
False -> emit_variant_constructor_positional(v)
}
pad
<> "Ok(\""
<> v.variant_name
<> "\") -> {\n"
<> fields_extract
<> field_decodes
<> pad
<> " Ok("
<> alias
<> "."
<> v.variant_name
<> "("
<> field_names
<> "))\n"
<> pad
<> "}\n"
}
}
}
fn emit_fields_extract(pad: String) -> String {
pad
<> "use fields <- result.try(\n"
<> pad
<> " json_runtime.field(value, \"fields\", \"fields\")\n"
<> pad
<> ")\n"
}
fn emit_fields_object_size_check(
path path: String,
expected_size expected_size: Int,
pad pad: String,
) -> String {
let expected = int.to_string(expected_size)
pad
<> "use _ <- result.try(\n"
<> pad
<> " case json_runtime.object_size(fields, \""
<> path
<> "\") {\n"
<> pad
<> " Ok(size) -> case size {\n"
<> pad
<> " "
<> expected
<> " -> Ok(Nil)\n"
<> pad
<> " n -> Error([JsonError(\""
<> path
<> "\", \"expected exactly "
<> expected
<> " object fields, got \" <> int.to_string(n))])\n"
<> pad
<> " }\n"
<> pad
<> " Error(errors) -> Error(errors)\n"
<> pad
<> " }\n"
<> pad
<> ")\n"
}
/// Generate field decode bindings for labelled fields (extracted by name).
fn emit_labelled_field_decodes(v: DiscoveredVariant, pad: String) -> String {
let zipped = list.zip(v.fields, v.field_labels)
let entries =
list.index_map(zipped, fn(pair, _i) {
let #(ft, label) = pair
case label {
Some(name) -> {
let path = "fields." <> name
let result_var = name <> "_result"
pad
<> "let "
<> result_var
<> " = case json_runtime.field(fields, \""
<> name
<> "\", \""
<> path
<> "\") {\n"
<> pad
<> " Ok(raw) -> "
<> emit_raw_value_decode(
ft: ft,
raw_var: "raw",
path: path,
pad: pad <> " ",
)
<> "\n"
<> pad
<> " Error(errors) -> Error(errors)\n"
<> pad
<> "}\n"
<> pad
<> "use "
<> name
<> " <- result.try("
<> result_var
<> ")\n"
}
None -> ""
// handled by check_no_mixed_fields
}
})
string.join(entries, "")
}
/// Generate field decode bindings for unlabelled fields (extracted by index).
fn emit_unlabelled_field_decodes(v: DiscoveredVariant, pad: String) -> String {
let field_count = list.length(v.fields)
let n_str = int.to_string(field_count)
// Validate array length
let length_check =
pad
<> "use arr <- result.try(\n"
<> pad
<> " case json_runtime.array_length(fields, \"fields\") {\n"
<> pad
<> " Ok(size) -> case size {\n"
<> pad
<> " "
<> n_str
<> " -> Ok(fields)\n"
<> pad
<> " n -> Error([JsonError(\"fields\", \"expected "
<> n_str
<> " elements, got \" <> int.to_string(n))])\n"
<> pad
<> " }\n"
<> pad
<> " Error(errors) -> Error(errors)\n"
<> pad
<> " }\n"
<> pad
<> ")\n"
let entries =
list.index_map(v.fields, fn(ft, i) {
let idx_str = int.to_string(i)
let path = "fields[" <> idx_str <> "]"
let var_name = "f" <> idx_str
let result_var = var_name <> "_result"
pad
<> "let "
<> result_var
<> " = case json_runtime.array_at(arr, "
<> idx_str
<> ", \""
<> path
<> "\") {\n"
<> pad
<> " Ok(raw) -> "
<> emit_raw_value_decode(
ft: ft,
raw_var: "raw",
path: path,
pad: pad <> " ",
)
<> "\n"
<> pad
<> " Error(errors) -> Error(errors)\n"
<> pad
<> "}\n"
<> pad
<> "use "
<> var_name
<> " <- result.try("
<> result_var
<> ")\n"
})
length_check <> string.join(entries, "")
}
/// Build labelled constructor field names e.g. `title:, body:`
fn emit_variant_constructor_labels(v: DiscoveredVariant) -> String {
let names =
list.filter_map(v.field_labels, fn(l) {
case l {
Some(n) -> Ok(n <> ":")
None -> Error(Nil)
}
})
string.join(names, ", ")
}
/// Build positional constructor field names e.g. `f0, f1`
fn emit_variant_constructor_positional(v: DiscoveredVariant) -> String {
let names = list.index_map(v.fields, fn(_, i) { "f" <> int.to_string(i) })
string.join(names, ", ")
}
/// Emit a decode expression for a raw Dynamic value according to FieldType.
/// Returns a single expression evaluating to `Result(value, List(JsonError))`.
fn emit_raw_value_decode(
ft ft: FieldType,
raw_var raw_var: String,
path path: String,
pad pad: String,
) -> String {
case ft {
StringField ->
pad <> "json_runtime.string(" <> raw_var <> ", \"" <> path <> "\")"
IntField -> pad <> "json_runtime.int(" <> raw_var <> ", \"" <> path <> "\")"
FloatField ->
pad <> "json_runtime.float(" <> raw_var <> ", \"" <> path <> "\")"
BoolField ->
pad <> "json_runtime.bool(" <> raw_var <> ", \"" <> path <> "\")"
NilField -> pad <> "json_runtime.nil(" <> raw_var <> ", \"" <> path <> "\")"
BitArrayField ->
pad
<> "case json_runtime.field_string("
<> raw_var
<> ", \"encoding\", \""
<> path
<> ".encoding\") {\n"
<> pad
<> " Ok(\"base64url\") -> case json_runtime.field_string("
<> raw_var
<> ", \"data\", \""
<> path
<> ".data\") {\n"
<> pad
<> " Ok(s) -> case bit_array.base64_url_decode(s) {\n"
<> pad
<> " Ok(bits) -> Ok(bits)\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> ".data\", \"expected valid base64url BitArray data\")])\n"
<> pad
<> " }\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> ".data\", \"missing or not a string\")])\n"
<> pad
<> " }\n"
<> pad
<> " Ok(other) -> Error([JsonError(\""
<> path
<> ".encoding\", \"expected base64url BitArray encoding, got \" <> other)])\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> ".encoding\", \"missing or not a string\")])\n"
<> pad
<> "}"
UserType(module_path:, type_name:, ..) -> {
let qual =
walker.qualified_atom_name(
module_path: module_path,
variant_name: type_name,
)
pad <> "json_decode_" <> qual <> "(" <> raw_var <> ")"
}
ListOf(element) ->
pad
<> "case json_runtime.list("
<> raw_var
<> ", \""
<> path
<> "\") {\n"
<> pad
<> " Ok(items) -> list.try_map(items, fn(item_raw) {\n"
<> emit_raw_value_decode(
ft: element,
raw_var: "item_raw",
path: path <> "[]",
pad: pad <> " ",
)
<> "\n"
<> pad
<> " })\n"
<> pad
<> " Error(errors) -> Error(errors)\n"
<> pad
<> "}"
OptionOf(inner) -> {
let qual = walker.qualified_atom_name("gleam/option", "Option")
pad
<> "json_decode_"
<> qual
<> "("
<> raw_var
<> ", fn(inner_raw) {\n"
<> emit_raw_value_decode(
ft: inner,
raw_var: "inner_raw",
path: path <> ".value",
pad: pad <> " ",
)
<> "\n"
<> pad
<> "})"
}
ResultOf(ok, err) -> {
let qual = walker.qualified_atom_name("gleam/result", "Result")
pad
<> "json_decode_"
<> qual
<> "("
<> raw_var
<> ", fn(inner_raw) {\n"
<> emit_raw_value_decode(
ft: ok,
raw_var: "inner_raw",
path: path <> ".ok",
pad: pad <> " ",
)
<> "\n"
<> pad
<> "}, fn(inner_raw) {\n"
<> emit_raw_value_decode(
ft: err,
raw_var: "inner_raw",
path: path <> ".error",
pad: pad <> " ",
)
<> "\n"
<> pad
<> "})"
}
DictOf(key, value) ->
emit_dict_decode(
key: key,
value: value,
raw_var: raw_var,
path: path,
pad: pad,
)
TupleOf(elements) ->
emit_tuple_decode(
elements: elements,
raw_var: raw_var,
path: path,
pad: pad,
)
TypeVar(name:) ->
pad
<> "Error([JsonError(\""
<> path
<> "\", \"cannot decode type variable "
<> name
<> "\")])"
}
}
/// Emit the builtin Option encoder function.
fn emit_option_encoder() -> String {
// nolint: unnecessary_string_concatenation -- codegen template
"pub fn json_encode_gleam_option__option(value, encode_inner: fn(a) -> json.Json) -> json.Json {\n"
<> " case value {\n"
<> " Some(inner) -> json.object([\n"
<> " #(\"type\", json.string(\"gleam/option.Option\")),\n"
<> " #(\"variant\", json.string(\"Some\")),\n"
<> " #(\"fields\", json.array([encode_inner(inner)], of: fn(x) { x })),\n"
<> " ])\n"
<> " None -> json.object([\n"
<> " #(\"type\", json.string(\"gleam/option.Option\")),\n"
<> " #(\"variant\", json.string(\"None\")),\n"
<> " #(\"fields\", json.object([])),\n"
<> " ])\n"
<> " }\n"
<> "}"
}
/// Emit the builtin Option decoder function.
fn emit_option_decoder() -> String {
// nolint: unnecessary_string_concatenation -- codegen template
"pub fn json_decode_gleam_option__option(value: Dynamic, decode_inner: fn(Dynamic) -> Result(a, List(JsonError))) -> Result(Option(a), List(JsonError)) {\n"
<> " use _ <- result.try(\n"
<> " case json_runtime.field_string(value, \"type\", \"type\") {\n"
<> " Ok(\"gleam/option.Option\") -> Ok(Nil)\n"
<> " Ok(s) -> Error([JsonError(\"type\", \"expected gleam/option.Option, got \" <> s)])\n"
<> " Error(_) -> Error([JsonError(\"type\", \"missing or not a string\")])\n"
<> " }\n"
<> " )\n"
<> " case json_runtime.field_string(value, \"variant\", \"variant\") {\n"
<> " Ok(\"None\") -> {\n"
<> " use fields <- result.try(json_runtime.field(value, \"fields\", \"fields\"))\n"
<> " use size <- result.try(json_runtime.object_size(fields, \"fields\"))\n"
<> " case size {\n"
<> " 0 -> Ok(None)\n"
<> " n -> Error([JsonError(\"fields\", \"expected exactly 0 object fields, got \" <> int.to_string(n))])\n"
<> " }\n"
<> " }\n"
<> " Ok(\"Some\") -> {\n"
<> " use fields <- result.try(json_runtime.field(value, \"fields\", \"fields\"))\n"
<> " use size <- result.try(json_runtime.array_length(fields, \"fields\"))\n"
<> " use _ <- result.try(case size { 1 -> Ok(Nil) n -> Error([JsonError(\"fields\", \"expected 1 elements, got \" <> int.to_string(n))]) })\n"
<> " use inner_raw <- result.try(json_runtime.array_at(fields, 0, \"fields[0]\"))\n"
<> " use val <- result.try(decode_inner(inner_raw))\n"
<> " Ok(Some(val))\n"
<> " }\n"
<> " Ok(other) -> Error([JsonError(\"variant\", \"unknown Option variant: \" <> other)])\n"
<> " Error(_) -> Error([JsonError(\"variant\", \"missing or not a string\")])\n"
<> " }\n"
<> "}"
}
/// Emit the builtin Result encoder function.
fn emit_result_encoder() -> String {
// nolint: unnecessary_string_concatenation -- codegen template
"pub fn json_encode_gleam_result__result(value, encode_ok: fn(a) -> json.Json, encode_err: fn(b) -> json.Json) -> json.Json {\n"
<> " case value {\n"
<> " Ok(inner) -> json.object([\n"
<> " #(\"type\", json.string(\"gleam/result.Result\")),\n"
<> " #(\"variant\", json.string(\"Ok\")),\n"
<> " #(\"fields\", json.array([encode_ok(inner)], of: fn(x) { x })),\n"
<> " ])\n"
<> " Error(inner) -> json.object([\n"
<> " #(\"type\", json.string(\"gleam/result.Result\")),\n"
<> " #(\"variant\", json.string(\"Error\")),\n"
<> " #(\"fields\", json.array([encode_err(inner)], of: fn(x) { x })),\n"
<> " ])\n"
<> " }\n"
<> "}"
}
/// Emit the builtin Result decoder function.
fn emit_result_decoder() -> String {
// nolint: unnecessary_string_concatenation -- codegen template
"pub fn json_decode_gleam_result__result(value: Dynamic, decode_ok: fn(Dynamic) -> Result(a, List(JsonError)), decode_err: fn(Dynamic) -> Result(b, List(JsonError))) -> Result(Result(a, b), List(JsonError)) {\n"
<> " use _ <- result.try(\n"
<> " case json_runtime.field_string(value, \"type\", \"type\") {\n"
<> " Ok(\"gleam/result.Result\") -> Ok(Nil)\n"
<> " Ok(s) -> Error([JsonError(\"type\", \"expected gleam/result.Result, got \" <> s)])\n"
<> " Error(_) -> Error([JsonError(\"type\", \"missing or not a string\")])\n"
<> " }\n"
<> " )\n"
<> " case json_runtime.field_string(value, \"variant\", \"variant\") {\n"
<> " Ok(\"Ok\") -> {\n"
<> " use fields <- result.try(json_runtime.field(value, \"fields\", \"fields\"))\n"
<> " use size <- result.try(json_runtime.array_length(fields, \"fields\"))\n"
<> " use _ <- result.try(case size { 1 -> Ok(Nil) n -> Error([JsonError(\"fields\", \"expected 1 elements, got \" <> int.to_string(n))]) })\n"
<> " use inner_raw <- result.try(json_runtime.array_at(fields, 0, \"fields[0]\"))\n"
<> " use val <- result.try(decode_ok(inner_raw))\n"
<> " Ok(Ok(val))\n"
<> " }\n"
<> " Ok(\"Error\") -> {\n"
<> " use fields <- result.try(json_runtime.field(value, \"fields\", \"fields\"))\n"
<> " use size <- result.try(json_runtime.array_length(fields, \"fields\"))\n"
<> " use _ <- result.try(case size { 1 -> Ok(Nil) n -> Error([JsonError(\"fields\", \"expected 1 elements, got \" <> int.to_string(n))]) })\n"
<> " use inner_raw <- result.try(json_runtime.array_at(fields, 0, \"fields[0]\"))\n"
<> " use val <- result.try(decode_err(inner_raw))\n"
<> " Ok(Error(val))\n"
<> " }\n"
<> " Ok(other) -> Error([JsonError(\"variant\", \"unknown Result variant: \" <> other)])\n"
<> " Error(_) -> Error([JsonError(\"variant\", \"missing or not a string\")])\n"
<> " }\n"
<> "}"
}
/// Generate inline decode for Dict types.
fn emit_dict_decode(
key key: FieldType,
value value: FieldType,
raw_var raw_var: String,
path path: String,
pad pad: String,
) -> String {
case key {
// String-keyed dict: decode as JSON object
StringField ->
pad
<> "case json_runtime.object_entries("
<> raw_var
<> ", \""
<> path
<> "\") {\n"
<> pad
<> " Ok(entries) -> {\n"
<> pad
<> " use decoded <- result.try(\n"
<> pad
<> " list.try_map(entries, fn(entry) {\n"
<> pad
<> " let k = entry.0\n"
<> pad
<> " let v_raw = entry.1\n"
<> pad
<> " use v <- result.try(\n"
<> emit_raw_value_decode(
ft: value,
raw_var: "v_raw",
path: path <> ".value",
pad: pad <> " ",
)
<> "\n"
<> pad
<> " )\n"
<> pad
<> " Ok(#(k, v))\n"
<> pad
<> " })\n"
<> pad
<> " )\n"
<> pad
<> " Ok(dict.from_list(decoded))\n"
<> pad
<> " }\n"
<> pad
<> " Error(errors) -> Error(errors)\n"
<> pad
<> "}"
// Non-string key: decode as array of pairs [[k1, v1], [k2, v2], ...]
_ ->
pad
<> "case json_runtime.pair_entries("
<> raw_var
<> ", \""
<> path
<> "\") {\n"
<> pad
<> " Ok(pairs_raw) -> {\n"
<> pad
<> " use pairs <- result.try(\n"
<> pad
<> " list.try_map(pairs_raw, fn(pair_raw) {\n"
<> pad
<> " let k_raw = pair_raw.0\n"
<> pad
<> " let v_raw = pair_raw.1\n"
<> pad
<> " use k <- result.try(\n"
<> emit_raw_value_decode(
ft: key,
raw_var: "k_raw",
path: path <> ".key",
pad: pad <> " ",
)
<> "\n"
<> pad
<> " )\n"
<> pad
<> " use v <- result.try(\n"
<> emit_raw_value_decode(
ft: value,
raw_var: "v_raw",
path: path <> ".value",
pad: pad <> " ",
)
<> "\n"
<> pad
<> " )\n"
<> pad
<> " Ok(#(k, v))\n"
<> pad
<> " })\n"
<> pad
<> " )\n"
<> pad
<> " Ok(dict.from_list(pairs))\n"
<> pad
<> " }\n"
<> pad
<> " Error(errors) -> Error(errors)\n"
<> pad
<> "}"
}
}
/// Generate inline decode for Tuple types.
fn emit_tuple_decode(
elements elements: List(FieldType),
raw_var raw_var: String,
path path: String,
pad pad: String,
) -> String {
let element_count = list.length(elements)
let n_str = int.to_string(element_count)
let fields_pad = pad <> " "
let field_decodes =
list.index_map(elements, fn(ft, i) {
let idx_str = int.to_string(i)
let var_name = "t" <> idx_str
let result_var = var_name <> "_result"
fields_pad
<> "let "
<> result_var
<> " = case json_runtime.array_at("
<> raw_var
<> ", "
<> idx_str
<> ", \""
<> path
<> "["
<> idx_str
<> "]\") {\n"
<> fields_pad
<> " Ok(raw) -> "
<> emit_raw_value_decode(
ft: ft,
raw_var: "raw",
path: path <> "[" <> idx_str <> "]",
pad: fields_pad <> " ",
)
<> "\n"
<> fields_pad
<> " Error(errors) -> Error(errors)\n"
<> fields_pad
<> "}\n"
<> fields_pad
<> "use "
<> var_name
<> " <- result.try("
<> result_var
<> ")\n"
})
let tuple_expr =
"#("
<> string.join(
list.index_map(elements, fn(_, i) { "t" <> int.to_string(i) }),
", ",
)
<> ")"
pad
<> "case json_runtime.array_length("
<> raw_var
<> ", \""
<> path
<> "\") {\n"
<> pad
<> " Ok(size) -> case size {\n"
<> pad
<> " "
<> n_str
<> " -> {\n"
<> string.join(field_decodes, "")
<> fields_pad
<> "Ok("
<> tuple_expr
<> ")\n"
<> pad
<> " }\n"
<> pad
<> " n -> Error([JsonError(\""
<> path
<> "\", \"expected "
<> n_str
<> " elements, got \" <> int.to_string(n))])\n"
<> pad
<> " }\n"
<> pad
<> " Error(errors) -> Error(errors)\n"
<> pad
<> "}"
}
fn collect_all_fields(discovered: List(DiscoveredType)) -> List(FieldType) {
list.flat_map(discovered, fn(dt) {
list.flat_map(dt.variants, fn(v) {
list.flat_map(v.fields, fn(ft) { flatten_field_type(ft) })
})
})
}
fn flatten_field_type(ft: FieldType) -> List(FieldType) {
case ft {
ListOf(inner) -> [ft, ..flatten_field_type(inner)]
OptionOf(inner) -> [ft, ..flatten_field_type(inner)]
ResultOf(ok, err) -> [
ft,
..list.append(flatten_field_type(ok), flatten_field_type(err))
]
DictOf(key, value) -> [
ft,
..list.append(flatten_field_type(key), flatten_field_type(value))
]
TupleOf(elements) -> [ft, ..list.flat_map(elements, flatten_field_type)]
_ -> [ft]
}
}
fn uses_dict(ft: FieldType) -> Bool {
case ft {
DictOf(_, _) -> True
_ -> False
}
}
fn uses_list(ft: FieldType) -> Bool {
case ft {
ListOf(_) -> True
_ -> False
}
}