Current section

Files

Jump to
libero src libero json codegen.gleam
Raw

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-RPC-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/field_type.{
type FieldType, BitArrayField, BoolField, DictOf, FloatField, IntField, ListOf,
NilField, OptionOf, ResultOf, StringField, TupleOf, TypeVar, UserType,
}
import libero/json/error.{type JsonError, JsonError}
import libero/walker.{type DiscoveredType, type DiscoveredVariant}
/// 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)) {
use _ <- result.try(check_no_mixed_fields(discovered))
let aliases = build_module_alias_map(discovered)
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 = collect_all_fields(discovered)
let needs_bit_array = list.any(all_fields, fn(ft) { ft == BitArrayField })
let needs_dict = list.any(all_fields, uses_dict)
let needs_list_at =
has_unlabelled_fields(discovered)
|| list.any(all_fields, fn(ft) {
case ft {
TupleOf(_) -> True
_ -> False
}
})
let needs_list_or_int =
needs_list_at || needs_dict || list.any(all_fields, uses_list_or_tuple)
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 = case needs_list_or_int {
True -> "import gleam/int\n"
False -> ""
}
let list_import = case needs_list_or_int {
True -> "import gleam/list\n"
False -> ""
}
let list_at_helper = case needs_list_at {
True ->
"/// Index into a list, returning Error if out of bounds.\nfn list_at(items: List(a), index: Int) -> Result(a, List(JsonError)) {\n case list.drop(items, index) |> list.first {\n Ok(item) -> Ok(item)\n Error(_) -> Error([JsonError(\"fields[\" <> int.to_string(index) <> \"]\", \"missing\")])\n }\n}\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/dynamic/decode\n"
<> "import gleam/json\n"
<> bit_array_import
<> "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
<> "\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
}
/// Build a map from module path to import alias for all modules referenced
/// by discovered types. Uses the last `/`-separated segment as the alias.
/// When two modules share the same last segment, the full underscored path
/// is used for both to avoid collisions.
fn build_module_alias_map(
discovered: List(DiscoveredType),
) -> Dict(String, String) {
let modules =
discovered
|> list.map(fn(dt) { dt.module_path })
|> list.unique
let segment_counts =
list.fold(modules, dict.new(), fn(acc, mod) {
let seg = field_type.last_segment(mod)
let count = case dict.get(acc, seg) {
Ok(n) -> n + 1
Error(Nil) -> 1
}
dict.insert(acc, seg, count)
})
list.fold(modules, dict.new(), fn(acc, mod) {
let seg = field_type.last_segment(mod)
let alias = case dict.get(segment_counts, seg) {
Ok(n) if n > 1 -> string.replace(mod, "/", "_")
_ -> seg
}
dict.insert(acc, mod, alias)
})
}
/// 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
<> " *. 0.0 == 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.string(bit_array.base64_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(_, 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 decode.run(value, decode.field(\"type\", decode.string, fn(x) { decode.success(x) })) {\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 decode.run(value, decode.field(\"variant\", decode.string, fn(x) { decode.success(x) })) {\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
<> "\") -> Ok("
<> alias
<> "."
<> v.variant_name
<> ")\n"
_ -> {
let all_labelled = list.all(v.field_labels, fn(l) { l != None })
let fields_extract =
pad
<> " use fields <- result.try(\n"
<> pad
<> " case decode.run(value, decode.field(\"fields\", decode.dynamic, fn(x) { decode.success(x) })) {\n"
<> pad
<> " Error(_) -> Error([JsonError(\"fields\", \"missing\")])\n"
<> pad
<> " Ok(f) -> Ok(f)\n"
<> pad
<> " }\n"
<> pad
<> " )\n"
let field_decodes = case all_labelled {
True -> 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"
}
}
}
/// 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 decode.run(fields, decode.field(\""
<> name
<> "\", decode.dynamic, fn(x) { decode.success(x) })) {\n"
<> pad
<> " Ok(raw) -> "
<> emit_raw_value_decode(
ft: ft,
raw_var: "raw",
path: path,
pad: pad <> " ",
)
<> "\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"missing\")])\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 decode.run(fields, decode.list(of: decode.dynamic)) {\n"
<> pad
<> " Ok(a) -> case list.length(a) {\n"
<> pad
<> " "
<> n_str
<> " -> Ok(a)\n"
<> pad
<> " n -> Error([JsonError(\"fields\", \"expected "
<> n_str
<> " elements, got \" <> int.to_string(n))])\n"
<> pad
<> " }\n"
<> pad
<> " Error(_) -> Error([JsonError(\"fields\", \"expected Array\")])\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 list_at(arr, "
<> idx_str
<> ") {\n"
<> pad
<> " Ok(raw) -> "
<> emit_raw_value_decode(
ft: ft,
raw_var: "raw",
path: path,
pad: pad <> " ",
)
<> "\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"missing\")])\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
<> "case decode.run("
<> raw_var
<> ", decode.string) {\n"
<> pad
<> " Ok(v) -> Ok(v)\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected String\")])\n"
<> pad
<> "}"
IntField ->
pad
<> "case decode.run("
<> raw_var
<> ", decode.int) {\n"
<> pad
<> " Ok(v) -> case v >= -9007199254740991 && v <= 9007199254740991 {\n"
<> pad
<> " True -> Ok(v)\n"
<> pad
<> " False -> Error([JsonError(\""
<> path
<> "\", \"expected Int in safe JSON range\")])\n"
<> pad
<> " }\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected Int\")])\n"
<> pad
<> "}"
FloatField ->
pad
<> "case decode.run("
<> raw_var
<> ", decode.float) {\n"
<> pad
<> " Ok(v) -> Ok(v)\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected Float\")])\n"
<> pad
<> "}"
BoolField ->
pad
<> "case decode.run("
<> raw_var
<> ", decode.bool) {\n"
<> pad
<> " Ok(v) -> Ok(v)\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected Bool\")])\n"
<> pad
<> "}"
NilField ->
pad
<> "case decode.run("
<> raw_var
<> ", decode.optional(decode.dynamic)) {\n"
<> pad
<> " Ok(None) -> Ok(Nil)\n"
<> pad
<> " Ok(Some(_)) -> Error([JsonError(\""
<> path
<> "\", \"expected null\")])\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected null\")])\n"
<> pad
<> "}"
BitArrayField ->
pad
<> "case decode.run("
<> raw_var
<> ", decode.string) {\n"
<> pad
<> " Ok(s) -> case bit_array.base64_decode(s) {\n"
<> pad
<> " Ok(bits) -> Ok(bits)\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected valid base64 BitArray\")])\n"
<> pad
<> " }\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected String (base64 BitArray)\")])\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 decode.run("
<> raw_var
<> ", decode.list(of: decode.dynamic)) {\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(_) -> Error([JsonError(\""
<> path
<> "\", \"expected Array\")])\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 {
"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 {
"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 decode.run(value, decode.field(\"type\", decode.string, fn(x) { decode.success(x) })) {\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 decode.run(value, decode.field(\"variant\", decode.string, fn(x) { decode.success(x) })) {\n Ok(\"None\") -> Ok(None)\n Ok(\"Some\") -> {\n case decode.run(value, decode.field(\"fields\", decode.list(of: decode.dynamic), fn(x) { decode.success(x) })) {\n Ok([inner_raw]) -> {\n use val <- result.try(decode_inner(inner_raw))\n Ok(Some(val))\n }\n Ok(_) -> Error([JsonError(\"fields\", \"expected [value] for Some\")])\n Error(_) -> Error([JsonError(\"fields\", \"expected array for Some\")])\n }\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 {
"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 {
"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 decode.run(value, decode.field(\"type\", decode.string, fn(x) { decode.success(x) })) {\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 decode.run(value, decode.field(\"variant\", decode.string, fn(x) { decode.success(x) })) {\n Ok(\"Ok\") -> {\n case decode.run(value, decode.field(\"fields\", decode.list(of: decode.dynamic), fn(x) { decode.success(x) })) {\n Ok([inner_raw]) -> {\n use val <- result.try(decode_ok(inner_raw))\n Ok(Ok(val))\n }\n Ok(_) -> Error([JsonError(\"fields\", \"expected [value] for Ok\")])\n Error(_) -> Error([JsonError(\"fields\", \"expected array for Ok\")])\n }\n }\n Ok(\"Error\") -> {\n case decode.run(value, decode.field(\"fields\", decode.list(of: decode.dynamic), fn(x) { decode.success(x) })) {\n Ok([inner_raw]) -> {\n use val <- result.try(decode_err(inner_raw))\n Ok(Error(val))\n }\n Ok(_) -> Error([JsonError(\"fields\", \"expected [value] for Error\")])\n Error(_) -> Error([JsonError(\"fields\", \"expected array for Error\")])\n }\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 decode.run("
<> raw_var
<> ", decode.dict(decode.string, decode.dynamic)) {\n"
<> pad
<> " Ok(entries) -> dict.fold(entries, Ok(dict.new()), fn(acc, k, v_raw) {\n"
<> pad
<> " use acc_dict <- result.try(acc)\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(dict.insert(acc_dict, k, v))\n"
<> pad
<> " })\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected Dict\")])\n"
<> pad
<> "}"
// Non-string key: decode as array of pairs [[k1, v1], [k2, v2], ...]
_ ->
pad
<> "case decode.run("
<> raw_var
<> ", decode.list(of: decode.dynamic)) {\n"
<> pad
<> " Ok(pairs_raw) -> {\n"
<> pad
<> " use pairs <- result.try(\n"
<> pad
<> " list.try_map(pairs_raw, fn(pair_raw) {\n"
<> pad
<> " case decode.run(pair_raw, decode.list(of: decode.dynamic)) {\n"
<> pad
<> " Ok([k_raw, v_raw]) -> {\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
<> " Ok(_) -> Error([JsonError(\""
<> path
<> "\", \"expected [key, value] pair\")])\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected Array pair\")])\n"
<> pad
<> " }\n"
<> pad
<> " })\n"
<> pad
<> " )\n"
<> pad
<> " Ok(dict.from_list(pairs))\n"
<> pad
<> " }\n"
<> pad
<> " Error(_) -> Error([JsonError(\""
<> path
<> "\", \"expected Array of pairs\")])\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 list_at(arr, "
<> 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(_) -> Error([JsonError(\""
<> path
<> "["
<> idx_str
<> "\", \"missing\")])\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 decode.run("
<> raw_var
<> ", decode.list(of: decode.dynamic)) {\n"
<> pad
<> " Ok(arr) -> case list.length(arr) {\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(_) -> Error([JsonError(\""
<> path
<> "\", \"expected Array\")])\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_or_tuple(ft: FieldType) -> Bool {
case ft {
ListOf(_) | TupleOf(_) -> True
_ -> False
}
}
fn has_unlabelled_fields(discovered: List(DiscoveredType)) -> Bool {
list.any(discovered, fn(dt) {
list.any(dt.variants, fn(v) {
list.any(v.field_labels, fn(l) { l == None })
})
})
}