Packages

A User-Friendly Component-Based Cardano SDK for Elixir.

Current section

Files

Jump to
sutra_cardano lib sutra cardano blueprint code_generator.ex
Raw

lib/sutra/cardano/blueprint/code_generator.ex

defmodule Sutra.Cardano.Blueprint.CodeGenerator do
@moduledoc """
Generates Elixir modules and structs from CIP-57 Blueprint schemas.
This module transforms resolved blueprint schemas into Elixir source code,
creating:
- Datum module with typed structs and encode/decode functions
- Redeemer module with simple functions for transaction building
- Validator metadata (hash, compiled code)
Uses EEx templates stored in `priv/blueprint_generator/` for cleaner code generation.
"""
alias Sutra.Cardano.Blueprint.Parser
@type generate_opts :: [
only_title: String.t() | nil,
only_spend: boolean(),
only_mint: boolean(),
only_withdraw: boolean(),
only_publish: boolean(),
only_vote: boolean()
]
# ============================================================================
# Template Loading
# ============================================================================
# Compile templates at compile time for better performance
@module_template File.read!(Path.join(:code.priv_dir(:sutra), "blueprint_generator/module.eex"))
@redeemer_template File.read!(
Path.join(:code.priv_dir(:sutra), "blueprint_generator/redeemer.eex")
)
@datum_template File.read!(Path.join(:code.priv_dir(:sutra), "blueprint_generator/datum.eex"))
@unit_module_template File.read!(
Path.join(:code.priv_dir(:sutra), "blueprint_generator/unit_module.eex")
)
@record_module_template File.read!(
Path.join(
:code.priv_dir(:sutra),
"blueprint_generator/record_module.eex"
)
)
@redeemer_simple_template File.read!(
Path.join(
:code.priv_dir(:sutra),
"blueprint_generator/redeemer_simple.eex"
)
)
# Mark external resources so module recompiles when templates change
@external_resource Path.join(:code.priv_dir(:sutra), "blueprint_generator/module.eex")
@external_resource Path.join(:code.priv_dir(:sutra), "blueprint_generator/redeemer.eex")
@external_resource Path.join(:code.priv_dir(:sutra), "blueprint_generator/redeemer_simple.eex")
@external_resource Path.join(:code.priv_dir(:sutra), "blueprint_generator/datum.eex")
@external_resource Path.join(:code.priv_dir(:sutra), "blueprint_generator/unit_module.eex")
@external_resource Path.join(:code.priv_dir(:sutra), "blueprint_generator/record_module.eex")
# ============================================================================
# Public API
# ============================================================================
@doc """
Generates Elixir module source code from a blueprint.
## Parameters
- `blueprint` - The parsed blueprint JSON
- `module_name` - The base module name to generate (e.g., "MyApp.Contracts")
- `opts` - Generation options
## Options
- `:only_title` - Only generate for validators starting with this title prefix
- `:only_spend` - Only generate for spend validators
- `:only_mint` - Only generate for mint validators
- `:only_withdraw` - Only generate for withdraw validators
- `:only_publish` - Only generate for publish validators
- `:only_vote` - Only generate for vote validators
## Returns
A map of `%{module_name => source_code}` for each generated module.
"""
@spec generate(map(), String.t(), generate_opts()) ::
{:ok, %{String.t() => String.t()}} | {:error, any()}
def generate(blueprint, module_name, opts \\ []) do
case Parser.parse_validators(blueprint) do
{:ok, validators} ->
filtered = filter_validators(validators, opts)
modules = generate_modules(filtered, module_name, blueprint["definitions"] || %{})
{:ok, modules}
error ->
error
end
end
@doc """
Returns the path to the templates directory.
"""
def templates_dir do
Path.join(:code.priv_dir(:sutra), "blueprint_generator")
end
# ============================================================================
# Validator Filtering
# ============================================================================
defp filter_validators(validators, opts) do
validators
|> filter_by_title(opts[:only_title])
|> filter_by_type(opts)
end
defp filter_by_title(validators, nil), do: validators
defp filter_by_title(validators, prefix) do
Enum.filter(validators, fn v ->
String.starts_with?(v.title, prefix)
end)
end
defp filter_by_type(validators, opts) do
active_filters = get_active_filters(opts)
if active_filters == [] do
validators
else
Enum.filter(validators, &validator_matches_any_filter?(&1, active_filters))
end
end
defp get_active_filters(opts) do
filter_map = [
only_spend: ".spend",
only_mint: ".mint",
only_withdraw: ".withdraw",
only_publish: ".publish",
only_vote: ".vote"
]
Enum.reduce(filter_map, [], fn {key, suffix}, acc ->
if opts[key], do: [suffix | acc], else: acc
end)
end
defp validator_matches_any_filter?(validator, filters) do
Enum.any?(filters, fn suffix -> String.ends_with?(validator.title, suffix) end)
end
# ============================================================================
# Module Generation
# ============================================================================
defp generate_modules(validators, base_module, definitions) do
grouped =
Enum.group_by(validators, fn v ->
v.title
|> String.split(".")
|> Enum.drop(-1)
|> Enum.join(".")
end)
Enum.reduce(grouped, %{}, fn {validator_base, group_validators}, acc ->
module_name = validator_module_name(base_module, validator_base)
source = generate_validator_module(module_name, group_validators, definitions)
Map.put(acc, module_name, source)
end)
end
defp validator_module_name(base_module, validator_base) do
parts =
validator_base
|> String.split(".")
|> Enum.map_join(".", &Macro.camelize/1)
"#{base_module}.#{parts}"
end
defp generate_validator_module(module_name, validators, _definitions) do
# Find spend validator for datum (datum only exists on spend validators)
spend_validator = Enum.find(validators, fn v -> String.ends_with?(v.title, ".spend") end)
# Generate datum module if spend validator has a datum schema
datum_module =
if spend_validator && spend_validator.datum_schema do
generate_datum_module(spend_validator.datum_schema)
else
""
end
# Generate redeemer modules for each validator purpose (except else)
redeemer_modules =
validators
|> Enum.reject(fn v -> String.ends_with?(v.title, ".else") end)
|> Enum.filter(fn v -> v.redeemer_schema != nil end)
|> Enum.map_join("\n\n", fn v ->
purpose = validator_type(v.title)
module_name = "Redeemer#{purpose |> Atom.to_string() |> Macro.camelize()}"
generate_redeemer_module(v.redeemer_schema, module_name, purpose)
end)
# Get hash and compiled code from first validator (they're all the same)
first_validator = hd(validators)
hash = first_validator.hash || ""
compiled_code = first_validator.compiled_code || ""
EEx.eval_string(@module_template,
assigns: [
module_name: module_name,
hash: hash,
compiled_code: compiled_code,
datum_module: datum_module,
redeemer_modules: redeemer_modules
]
)
end
# ============================================================================
# Redeemer Module Generation
# ============================================================================
defp generate_redeemer_module(%{"anyOf" => variants} = schema, module_name, purpose) do
# Build variant info for the template
variant_infos =
Enum.map(variants, fn variant ->
fields = variant["fields"] || []
field_args = generate_redeemer_field_args(fields)
field_map = generate_redeemer_field_map(fields)
%{
title: variant["title"],
index: variant["index"],
fields: fields,
field_args: field_args,
field_map: field_map
}
end)
EEx.eval_string(@redeemer_template,
assigns: [
module_name: module_name,
purpose: purpose,
schema: schema,
variants: variant_infos
]
)
end
# Handle list type redeemers (like WithdrawSignatures which is a list of tuples)
defp generate_redeemer_module(%{"dataType" => "list"} = schema, module_name, purpose) do
title = schema["title"] || "List"
func_name = title |> Macro.underscore()
EEx.eval_string(@redeemer_simple_template,
assigns: [
module_name: module_name,
purpose: purpose,
schema: schema,
title: title,
func_name: func_name
]
)
end
# Handle constructor type redeemers (single constructor)
defp generate_redeemer_module(%{"dataType" => "constructor"} = schema, module_name, purpose) do
# Treat as a single-variant enum
generate_redeemer_module(%{"anyOf" => [schema]}, module_name, purpose)
end
# Skip Data type (any plutus data) and empty schemas
defp generate_redeemer_module(%{"title" => "Data"}, _module_name, _purpose), do: ""
defp generate_redeemer_module(_schema, _module_name, _purpose), do: ""
defp generate_redeemer_field_args(fields) do
fields
|> Enum.with_index()
|> Enum.map_join(", ", fn {f, idx} ->
name = f["title"] || "field_#{idx}"
Macro.underscore(name)
end)
end
defp generate_redeemer_field_map(fields) do
fields
|> Enum.with_index()
|> Enum.map_join(", ", fn {f, idx} ->
name = f["title"] || "field_#{idx}"
arg_name = Macro.underscore(name)
"\"#{name}\" => #{arg_name}"
end)
end
# ============================================================================
# Datum Module Generation
# ============================================================================
defp generate_datum_module(%{"anyOf" => variants}) when length(variants) == 1 do
# Single constructor - generate as a simple struct
[variant] = variants
generate_datum_struct(variant)
end
defp generate_datum_module(%{"anyOf" => variants}) do
# Multiple constructors - generate as enum with variant submodules
variant_modules =
variants
|> Enum.map_join("\n\n", fn variant ->
generate_datum_variant_module(variant)
end)
type_union = Enum.map_join(variants, " | ", fn v -> v["title"] <> ".t()" end)
EEx.eval_string(@datum_template,
assigns: [
is_enum: true,
variants: variants,
type_union: type_union,
variant_modules: variant_modules
]
)
end
defp generate_datum_module(%{"dataType" => "constructor"} = schema) do
generate_datum_struct(schema)
end
# Handle "Data" type - any plutus data, no specific structure
defp generate_datum_module(%{"title" => "Data"}), do: ""
defp generate_datum_module(_), do: ""
defp generate_datum_struct(%{"fields" => fields, "index" => index}) when is_list(fields) do
if fields == [] do
# Unit datum (no fields)
EEx.eval_string(@datum_template,
assigns: [
is_enum: false,
struct_fields: [],
field_types: "",
to_plutus_fields: "",
from_plutus_fields: "",
field_pattern: "",
index: index
]
)
else
field_names = extract_field_names(fields)
struct_fields = Enum.map(field_names, &String.to_atom/1)
EEx.eval_string(@datum_template,
assigns: [
is_enum: false,
struct_fields: struct_fields,
field_types: generate_field_types(fields),
to_plutus_fields: generate_to_plutus_fields(fields),
from_plutus_fields: generate_from_plutus_fields(fields),
field_pattern: generate_field_pattern(fields),
index: index
]
)
end
end
defp generate_datum_struct(_), do: ""
defp generate_datum_variant_module(%{"fields" => fields, "index" => index, "title" => title})
when is_list(fields) do
if fields == [] do
EEx.eval_string(@unit_module_template,
assigns: [
name: title,
index: index
]
)
else
field_names = extract_field_names(fields)
struct_fields = Enum.map(field_names, &String.to_atom/1)
EEx.eval_string(@record_module_template,
assigns: [
name: title,
index: index,
struct_fields: struct_fields,
field_types: generate_field_types(fields),
to_plutus_fields: generate_to_plutus_fields(fields),
from_plutus_fields: generate_from_plutus_fields(fields),
field_pattern: generate_field_pattern(fields)
]
)
end
end
defp generate_datum_variant_module(_), do: ""
# ============================================================================
# Field Helpers
# ============================================================================
defp extract_field_names(fields) do
Enum.with_index(fields)
|> Enum.map(fn {f, idx} ->
f["title"] || "field_#{idx}"
end)
end
defp generate_field_types(fields) do
Enum.with_index(fields)
|> Enum.map_join(",\n", fn {f, idx} ->
name = f["title"] || "field_#{idx}"
type = field_type_spec(f)
" #{name}: #{type}"
end)
end
defp field_type_spec(%{"dataType" => "integer"}), do: "integer()"
defp field_type_spec(%{"dataType" => "bytes"}), do: "binary()"
defp field_type_spec(%{"dataType" => "list", "items" => items}) when is_map(items) do
"[#{field_type_spec(items)}]"
end
defp field_type_spec(%{"anyOf" => _} = schema) do
if schema["title"], do: "#{schema["title"]}.t()", else: "term()"
end
defp field_type_spec(_), do: "term()"
defp generate_field_pattern(fields) do
Enum.map_join(0..(length(fields) - 1), ", ", fn i -> "f#{i}" end)
end
defp generate_to_plutus_fields(fields) do
Enum.with_index(fields)
|> Enum.map_join(",\n", fn {f, idx} ->
name = f["title"] || "field_#{idx}"
atom_name = String.to_atom(name)
encode_expr = field_to_plutus(f, "data.#{atom_name}")
" #{encode_expr}"
end)
end
defp field_to_plutus(%{"dataType" => "bytes"}, expr) do
"%CBOR.Tag{tag: :bytes, value: #{expr}}"
end
defp field_to_plutus(%{"dataType" => "integer"}, expr), do: expr
defp field_to_plutus(%{"dataType" => "list", "items" => items}, expr) when is_map(items) do
inner = field_to_plutus(items, "v")
"%PList{value: Enum.map(#{expr}, fn v -> #{inner} end)}"
end
defp field_to_plutus(%{"anyOf" => _}, expr) do
"#{expr}.__struct__.to_plutus(#{expr})"
end
defp field_to_plutus(_, expr), do: expr
defp generate_from_plutus_fields(fields) do
Enum.with_index(fields)
|> Enum.map_join(",\n", fn {f, idx} ->
name = f["title"] || "field_#{idx}"
atom_name = String.to_atom(name)
decode_expr = field_from_plutus(f, "f#{idx}")
" #{atom_name}: #{decode_expr}"
end)
end
defp field_from_plutus(%{"dataType" => "bytes"}, expr) do
"case #{expr} do %CBOR.Tag{tag: :bytes, value: v} -> v; v -> v end"
end
defp field_from_plutus(%{"dataType" => "integer"}, expr), do: expr
defp field_from_plutus(%{"dataType" => "list", "items" => items}, expr) when is_map(items) do
inner = field_from_plutus(items, "v")
"case #{expr} do %PList{value: l} -> Enum.map(l, fn v -> #{inner} end); l -> l end"
end
defp field_from_plutus(_, expr), do: expr
# ============================================================================
# Helpers
# ============================================================================
@doc """
Determines the validator type from its title.
"""
def validator_type(title) do
cond do
String.ends_with?(title, ".spend") -> :spend
String.ends_with?(title, ".mint") -> :mint
String.ends_with?(title, ".withdraw") -> :withdraw
String.ends_with?(title, ".publish") -> :publish
String.ends_with?(title, ".vote") -> :vote
String.ends_with?(title, ".else") -> :else
true -> :unknown
end
end
end