Current section

Files

Jump to
libero src libero codegen_decoders.gleam
Raw

src/libero/codegen_decoders.gleam

//// Typed decoder generators.
////
//// Emits the `rpc_decoders.gleam` Gleam wrapper and the per-type
//// `rpc_decoders_ffi.mjs` JavaScript module that the generated client
//// stubs use to decode ETF responses into typed Gleam values.
import gleam/int
import gleam/list
import gleam/set
import gleam/string
import libero/codegen
import libero/config.{type Config}
import libero/field_type.{
type FieldType, BitArrayField, BoolField, DictOf, FloatField, IntField, ListOf,
NilField, OptionOf, ResultOf, StringField, TupleOf, TypeVar, UserType,
}
import libero/gen_error.{type GenError}
import libero/scanner
import libero/walker.{type DiscoveredType, type DiscoveredVariant}
/// Write the Gleam wrapper for the typed decoder FFI.
/// Surfaces `ensure_decoders` from the generated JS FFI file to Gleam
/// callers, which triggers constructor and decoder registration on
/// import. JS-only, no Erlang target fallback.
pub fn write_decoders_gleam(config config: Config) -> Result(Nil, GenError) {
let content =
"//// Code generated by libero. DO NOT EDIT.
////
//// Gleam wrapper for the typed decoder FFI. Importing this module
//// triggers constructor and decoder registration on the JavaScript target.
@external(javascript, \"./rpc_decoders_ffi.mjs\", \"ensure_decoders\")
pub fn ensure_decoders() -> Bool
"
let output = config.decoders_gleam_output
codegen.ensure_parent_dir(path: output)
codegen.write_file(path: output, content: content)
}
/// Emit a JS string with one decoder function per discovered type and an
/// `ensure_decoders` entry point. Does not write to disk; exposed so
/// tests can assert on the output without filesystem I/O.
pub fn emit_typed_decoders(discovered: List(DiscoveredType)) -> String {
let type_decoders = list.map(discovered, fn(t) { emit_type_decoder(t) })
let entry = emit_ensure_decoders()
let parts =
list.filter([string.join(type_decoders, "\n\n"), entry], fn(s) { s != "" })
string.join(parts, "\n\n")
}
/// Write the typed decoders FFI file for the consumer. Per-endpoint
/// response decoders are appended after the per-type decoders.
pub fn write_decoders_ffi(
config config: Config,
discovered discovered: List(DiscoveredType),
endpoints endpoints: List(scanner.HandlerEndpoint),
) -> Result(Nil, GenError) {
let imports = emit_decoder_imports(discovered:, config:, endpoints:)
let body = emit_typed_decoders(discovered)
let response_decoders = emit_response_decoders(endpoints)
let float_field_registrations = emit_float_field_registrations(discovered)
// Inject stdlib constructor setters at module load time so that
// decode_result_of / decode_option_of / decode_list_of work correctly.
// nolint: unnecessary_string_concatenation -- codegen template, clarity over concat
let ctor_setters =
"setResultCtors(Ok, ResultError);\n"
<> "setOptionCtors(Some, None);\n"
<> "setListCtors(Empty, NonEmpty);\n"
<> "setDictFromList(dictFromList);\n"
let content = "// Code generated by libero. DO NOT EDIT.
//
// Per-type decoder functions derived from the DiscoveredType graph.
// Eliminates the global constructor registry - each decoder knows
// exactly which module's constructor to instantiate.
" <> imports <> "\n\n" <> ctor_setters <> float_field_registrations <> "\n" <> body <> "\n" <> response_decoders
let output = config.decoders_ffi_output
codegen.ensure_parent_dir(path: output)
codegen.write_file(path: output, content: content)
}
/// Emit a JS import block for the typed decoders file. The endpoint
/// list determines whether the per-endpoint response decoders need the
/// RemoteData constructors imported.
fn emit_decoder_imports(
discovered discovered: List(DiscoveredType),
config config: Config,
endpoints endpoints: List(scanner.HandlerEndpoint),
) -> String {
// Collect unique module paths in discovery order.
let module_paths =
list.fold(discovered, #([], set.new()), fn(acc, t) {
let #(paths_acc, seen) = acc
case set.contains(seen, t.module_path) {
True -> acc
False -> #(
// O(n) append preserves discovery order for stable import output.
// Acceptable: n is the number of unique modules (typically < 20).
list.append(paths_acc, [t.module_path]),
set.insert(seen, t.module_path),
)
}
})
|> fn(pair) { pair.0 }
let prelude_import =
"import { decode_int, decode_float, decode_string, decode_bool, "
<> "decode_bit_array, decode_nil, decode_list_of, decode_option_of, "
<> "decode_result_of, decode_dict_of, decode_tuple_of, DecodeError, "
<> "setResultCtors, setOptionCtors, setListCtors, "
<> "setDictFromList } from \""
<> config.decoders_prelude_import_path
<> "\";"
let prefix = config.register_relpath_prefix
let stdlib_imports =
"import { Ok, Error as ResultError, Empty, NonEmpty } from \""
<> prefix
<> "gleam_stdlib/gleam.mjs\";\n"
<> "import { Some, None } from \""
<> prefix
<> "gleam_stdlib/gleam/option.mjs\";\n"
<> "import { from_list as dictFromList } from \""
<> prefix
<> "gleam_stdlib/gleam/dict.mjs\";"
let float_import = case discovered_has_float_fields(discovered) {
True ->
"\nimport { registerFloatFields } from \""
<> prefix
<> "libero/libero/rpc_ffi.mjs\";"
False -> ""
}
let module_imports =
list.map(module_paths, fn(mp) {
"import * as _m_"
<> codegen.module_to_underscored(mp)
<> " from \""
<> config.register_relpath_prefix
<> codegen.module_to_mjs_path(mp)
<> "\";"
})
let remote_data_import = case endpoints {
[] -> ""
_ ->
"\nimport { Success as _Success, Failure as _Failure, "
<> "TransportError as _TransportError, DomainError as _DomainError } from \""
<> prefix
<> "libero/libero/remote_data.mjs\";"
}
let rpc_error_import = case endpoints {
[] -> ""
_ ->
"\nimport { MalformedRequest as _MalformedRequest, "
<> "UnknownFunction as _UnknownFunction, "
<> "InternalError as _InternalError } from \""
<> prefix
<> "libero/libero/error.mjs\";"
}
string.join(
[
prelude_import,
stdlib_imports <> float_import <> remote_data_import <> rpc_error_import,
..module_imports
],
"\n",
)
}
fn discovered_has_float_fields(discovered: List(DiscoveredType)) -> Bool {
list.any(discovered, fn(t) {
list.any(t.variants, fn(v) { !list.is_empty(v.float_field_indices) })
})
}
fn emit_float_field_registrations(discovered: List(DiscoveredType)) -> String {
let lines =
list.flat_map(discovered, fn(t) {
list.filter_map(t.variants, fn(v) {
case v.float_field_indices {
[] -> Error(Nil)
indices -> {
let index_list =
indices
|> list.map(int.to_string)
|> string.join(", ")
Ok(
"registerFloatFields(\""
<> v.atom_name
<> "\", ["
<> index_list
<> "]);",
)
}
}
})
})
case lines {
[] -> ""
_ -> string.join(lines, "\n") <> "\n"
}
}
/// Derive the JS decoder function name for a discovered type.
/// e.g. ("shared/line_item", "Status") -> "decode_shared_line_item_status"
fn decoder_fn_name(module_path: String, type_name: String) -> String {
"decode_"
<> codegen.module_to_underscored(module_path)
<> "_"
<> walker.to_snake_case(type_name)
}
/// True when every variant in the list has zero fields (pure enum).
fn all_variants_zero_arity(variants: List(DiscoveredVariant)) -> Bool {
list.all(variants, fn(v) { list.is_empty(v.fields) })
}
/// Emit one JS decoder function for a discovered type.
fn emit_type_decoder(t: DiscoveredType) -> String {
let fn_name = decoder_fn_name(t.module_path, t.type_name)
let body = case t.variants {
[] -> " throw new DecodeError(\"empty type\");"
[single] ->
case single.fields {
[] -> emit_enum_decoder(t.variants)
_ -> emit_record_decoder(single)
}
variants ->
case all_variants_zero_arity(variants) {
True -> emit_enum_decoder(variants)
False -> emit_tagged_union_decoder(variants)
}
}
"export function " <> fn_name <> "(term) {\n" <> body <> "\n}"
}
/// Emit `new _m_<alias>.<Variant>(<args>)` for a discovered variant.
/// `args` is joined into the constructor call as-is; pass `""` for a
/// zero-arg variant to get `new _m_alias.Variant()`.
fn emit_variant_constructor_call(
variant: DiscoveredVariant,
args: String,
) -> String {
"new _m_"
<> codegen.module_to_underscored(variant.module_path)
<> "."
<> variant.variant_name
<> "("
<> args
<> ")"
}
/// Emit the body of a pure-enum decoder (no-field variants only).
fn emit_enum_decoder(variants: List(DiscoveredVariant)) -> String {
let cases =
list.map(variants, fn(v) {
" if (term === \""
<> v.atom_name
<> "\") return "
<> emit_variant_constructor_call(v, "")
<> ";"
})
string.join(cases, "\n")
<> "\n throw new DecodeError(\"unknown variant: \" + String(term));"
}
/// Emit the body of a single-variant record decoder.
fn emit_record_decoder(variant: DiscoveredVariant) -> String {
let field_lines =
list.index_map(variant.fields, fn(ft, i) {
" " <> field_decoder_call(ft, "term[" <> int.to_string(i + 1) <> "]")
})
let args = "\n" <> string.join(field_lines, ",\n") <> "\n "
" return " <> emit_variant_constructor_call(variant, args) <> ";"
}
/// Emit the body of a multi-variant tagged union decoder.
fn emit_tagged_union_decoder(variants: List(DiscoveredVariant)) -> String {
let arms =
list.map(variants, fn(v) {
let args = case v.fields {
[] -> ""
fields ->
fields
|> list.index_map(fn(ft, i) {
field_decoder_call(ft, "term[" <> int.to_string(i + 1) <> "]")
})
|> string.join(", ")
}
" case \""
<> v.atom_name
<> "\":\n return "
<> emit_variant_constructor_call(v, args)
<> ";"
})
" const tag = Array.isArray(term) ? term[0] : term;\n"
<> " switch (tag) {\n"
<> string.join(arms, "\n")
<> "\n default:\n throw new DecodeError(\"unknown variant: \" + String(tag));\n"
<> " }"
}
/// Produce the JS expression that decodes `term_expr` according to `ft`.
/// `depth` tracks nesting to generate unique lambda param names (t0, t1, ...).
fn field_decoder_call(ft: FieldType, term_expr: String) -> String {
field_decoder_call_depth(ft, term_expr, 0)
}
// nolint: label_possible -- internal recursive helper, labels add noise
fn field_decoder_call_depth(
ft: FieldType,
term_expr: String,
depth: Int,
) -> String {
let param = "t" <> int.to_string(depth)
let next = depth + 1
case ft {
IntField -> "decode_int(" <> term_expr <> ")"
FloatField -> "decode_float(" <> term_expr <> ")"
StringField -> "decode_string(" <> term_expr <> ")"
BoolField -> "decode_bool(" <> term_expr <> ")"
BitArrayField -> "decode_bit_array(" <> term_expr <> ")"
NilField -> "decode_nil(" <> term_expr <> ")"
ListOf(inner) ->
"decode_list_of(("
<> param
<> ") => "
<> field_decoder_call_depth(inner, param, next)
<> ", "
<> term_expr
<> ")"
OptionOf(inner) ->
"decode_option_of(("
<> param
<> ") => "
<> field_decoder_call_depth(inner, param, next)
<> ", "
<> term_expr
<> ")"
ResultOf(ok, err) ->
emit_higher_order_decoder(
fn_name: "decode_result_of",
inners: [ok, err],
term_expr:,
start_depth: next,
)
DictOf(k, v) ->
emit_higher_order_decoder(
fn_name: "decode_dict_of",
inners: [k, v],
term_expr:,
start_depth: next,
)
TupleOf(elems) -> {
let decoders = emit_lambda_decoders(inners: elems, start_depth: next)
"decode_tuple_of([" <> decoders <> "], " <> term_expr <> ")"
}
TypeVar(name) ->
"(() => { throw new DecodeError(\"TypeVar<"
<> name
<> "> not supported at runtime\"); })()"
UserType(module_path, type_name, _args) ->
decoder_fn_name(module_path, type_name) <> "(" <> term_expr <> ")"
}
}
/// Emit `<fn_name>(<lambda1>, <lambda2>, ..., <term_expr>)` where each
/// lambda decodes one of `inners`. Used for the Result/Dict shape where
/// the wrapping JS function takes per-position decoders followed by the
/// term to decode. `start_depth` is the depth assigned to the first
/// lambda's parameter; subsequent lambdas advance by 1 each.
fn emit_higher_order_decoder(
fn_name fn_name: String,
inners inners: List(FieldType),
term_expr term_expr: String,
start_depth start_depth: Int,
) -> String {
let decoders = emit_lambda_decoders(inners:, start_depth:)
fn_name <> "(" <> decoders <> ", " <> term_expr <> ")"
}
/// Emit a comma-joined list of `(t<n>) => <decoder>` lambdas, one per
/// element of `inners`. The first lambda uses parameter `t<start_depth>`
/// and each subsequent lambda increments. The recursive decoder body
/// starts from `param_depth + 1` so it never reuses the lambda's param.
fn emit_lambda_decoders(
inners inners: List(FieldType),
start_depth start_depth: Int,
) -> String {
inners
|> list.index_map(fn(ft, i) {
let p_depth = start_depth + i
let param = "t" <> int.to_string(p_depth)
"(" <> param <> ") => " <> field_decoder_call_depth(ft, param, p_depth + 1)
})
|> string.join(", ")
}
/// Emit the `ensure_decoders` FFI export. This is a no-op function whose
/// only purpose is to force the JS module to load, triggering the
/// constructor registration side effects that run at module scope.
fn emit_ensure_decoders() -> String {
"export function ensure_decoders() { return true; }"
}
/// Emit a hand-written decoder for `RpcError`. The shape is fixed
/// (libero/error.gleam: `MalformedRequest | UnknownFunction(name) |
/// InternalError(trace_id, message)`) so it isn't discovered through
/// the type walker. Generated once, called by every per-endpoint
/// response decoder for the outer-Error path.
fn emit_rpc_error_decoder() -> String {
// nolint: unnecessary_string_concatenation -- codegen template, clarity over concat
"function _decode_rpc_error(term) {\n"
<> " if (term === \"malformed_request\") return new _MalformedRequest();\n"
<> " if (Array.isArray(term)) {\n"
<> " if (term[0] === \"unknown_function\") return new _UnknownFunction(decode_string(term[1]));\n"
<> " if (term[0] === \"internal_error\") return new _InternalError(decode_string(term[1]), decode_string(term[2]));\n"
<> " }\n"
<> " return new _MalformedRequest();\n"
<> "}"
}
/// Emit the shared response-shape decoder. Maps the wire shape
/// `Result(Result(payload, domain), RpcError)` into an
/// `RpcData(payload, domain)` value. Per-endpoint decoders just supply
/// the two payload-level decoders, removing ~14 lines of boilerplate
/// each from the generated FFI.
fn emit_response_helper() -> String {
// nolint: unnecessary_string_concatenation -- codegen template, clarity over concat
"function _decode_response(raw, decode_ok, decode_err) {\n"
<> " if (Array.isArray(raw)) {\n"
<> " if (raw[0] === \"ok\") {\n"
<> " const inner = raw[1];\n"
<> " if (Array.isArray(inner) && inner[0] === \"ok\") {\n"
<> " return new _Success(decode_ok(inner[1]));\n"
<> " } else if (Array.isArray(inner) && inner[0] === \"error\") {\n"
<> " return new _Failure(new _DomainError(decode_err(inner[1])));\n"
<> " }\n"
<> " } else if (raw[0] === \"error\") {\n"
<> " return new _Failure(new _TransportError(_decode_rpc_error(raw[1])));\n"
<> " }\n"
<> " }\n"
<> " return new _Failure(new _TransportError(new _MalformedRequest()));\n"
<> "}"
}
/// Emit per-endpoint response decoder functions for the FFI file.
/// Each decoder is a thin wrapper around `_decode_response`, supplying the
/// payload and domain-error decoders for that endpoint's `Result` return.
fn emit_response_decoders(endpoints: List(scanner.HandlerEndpoint)) -> String {
case endpoints {
[] -> ""
_ -> {
let decoders =
list.map(endpoints, fn(e) {
let ok_decoder = field_decoder_call(e.return_ok, "t")
let err_decoder = field_decoder_call(e.return_err, "t")
"export function decode_response_"
<> e.fn_name
<> "(raw) {\n"
<> " return _decode_response(raw, (t) => "
<> ok_decoder
<> ", (t) => "
<> err_decoder
<> ");\n"
<> "}"
})
"\n// --- Per-endpoint response decoders ---\n\n"
<> emit_rpc_error_decoder()
<> "\n\n"
<> emit_response_helper()
<> "\n\n"
<> string.join(decoders, "\n\n")
<> "\n"
}
}
}