use crate::codegen::doc_emission::doc_first_paragraph_joined;
use crate::codegen::shared::binding_fields;
use crate::codegen::type_mapper::TypeMapper;
use crate::core::config::ResolvedCrateConfig;
use crate::core::hash::{self, CommentStyle};
use crate::core::ir::{FieldDef, TypeDef, TypeRef};
use ahash::AHashSet;
use heck::{ToPascalCase, ToSnakeCase};
use std::collections::HashMap;
use crate::backends::rustler::template_env;
pub(super) fn get_module_info(_api: &crate::core::ir::ApiSurface, config: &ResolvedCrateConfig) -> (String, String) {
let app_name = config.elixir_app_name();
let module_prefix = app_name.to_pascal_case();
(app_name, module_prefix)
}
pub(super) fn emit_elixir_doc_attr(out: &mut String, attr: &str, doc: &str, indent: &str) {
if doc.trim().is_empty() {
return;
}
let trimmed = doc.trim_end_matches('\n');
if !trimmed.contains('\n') {
let escaped = trimmed.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(indent);
out.push('@');
out.push_str(attr);
out.push_str(" \"");
out.push_str(&escaped);
out.push_str("\"\n");
return;
}
out.push_str(indent);
out.push('@');
out.push_str(attr);
out.push_str(" \"\"\"\n");
for line in trimmed.lines() {
let safe = line.replace("\"\"\"", "\"\" \"");
if safe.is_empty() {
out.push('\n');
} else {
out.push_str(indent);
out.push_str(&safe);
out.push('\n');
}
}
out.push_str(indent);
out.push_str("\"\"\"\n");
}
pub(super) fn gen_rustler_unimplemented_body(return_type: &TypeRef, fn_name: &str, has_error: bool) -> String {
let err_msg = format!("Not implemented: {fn_name}");
if has_error {
format!("Err(String::from(\"{err_msg}\"))")
} else {
match return_type {
TypeRef::Unit => "()".to_string(),
TypeRef::String | TypeRef::Char | TypeRef::Path => format!("String::from(\"[unimplemented: {fn_name}]\")"),
TypeRef::Bytes => "Vec::new()".to_string(),
TypeRef::Primitive(p) => match p {
crate::core::ir::PrimitiveType::Bool => "false".to_string(),
crate::core::ir::PrimitiveType::F32 | crate::core::ir::PrimitiveType::F64 => "0.0".to_string(),
_ => "0".to_string(),
},
TypeRef::Optional(_) => "None".to_string(),
TypeRef::Vec(_) => "Vec::new()".to_string(),
TypeRef::Map(_, _) => "Default::default()".to_string(),
TypeRef::Duration => "0u64".to_string(),
TypeRef::Named(_) | TypeRef::Json => format!("panic!(\"alef: {fn_name} not auto-delegatable\")"),
}
}
}
pub(super) fn map_return_type(
ty: &TypeRef,
mapper: &crate::backends::rustler::type_map::RustlerMapper,
opaque_types: &AHashSet<String>,
) -> String {
match ty {
TypeRef::Named(n) if opaque_types.contains(n) => format!("ResourceArc<{n}>"),
TypeRef::Optional(inner) => {
if let TypeRef::Named(n) = inner.as_ref() {
if opaque_types.contains(n) {
return format!("Option<ResourceArc<{n}>>");
}
}
mapper.map_type(ty)
}
_ => mapper.map_type(ty),
}
}
pub(super) fn gen_native_ex(
api: &crate::core::ir::ApiSurface,
app_name: &str,
app_module: &str,
_crate_name: &str,
config: &ResolvedCrateConfig,
exclude_functions: &AHashSet<&str>,
exclude_types: &AHashSet<&str>,
) -> String {
let mut out = String::with_capacity(1024);
let repo_url = config.github_repo();
let build_env_var = format!("{}_BUILD", app_name.to_uppercase());
let default_nif_targets: &[&str] = &[
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-gnu",
];
let nif_targets = match config.elixir.as_ref() {
Some(elixir) if !elixir.nif_targets.is_empty() => elixir.nif_targets.join(" "),
_ => default_nif_targets.join(" "),
};
out.push_str(&hash::header(CommentStyle::Hash));
let ctx = minijinja::context! {
app_module => app_module,
app_name => app_name,
repo_url => repo_url,
build_env_var => build_env_var,
nif_targets => nif_targets,
};
out.push_str(&template_env::render("native_module_header.jinja", ctx));
let mut last_was_multiline = true;
for func in api
.functions
.iter()
.filter(|f| !exclude_functions.contains(f.name.as_str()))
{
let fn_name = if func.is_async {
let n = func.name.as_str();
if n.ends_with("_async") {
n.to_string()
} else {
format!("{n}_async")
}
} else {
func.name.clone()
};
let underscored_params: Vec<String> = func
.params
.iter()
.map(|p| format!("_{}", p.name.to_snake_case()))
.collect();
if write_nif_doc(&mut out, &func.doc, last_was_multiline) {
last_was_multiline = true;
}
last_was_multiline = write_nif_stub(&mut out, &fn_name, &underscored_params, last_was_multiline);
let has_visitor_bridge = config.trait_bridges.iter().any(|b| {
b.bind_via != crate::core::config::BridgeBinding::OptionsField
&& func.params.iter().any(|p| {
b.param_name.as_deref() == Some(p.name.as_str()) || {
let named = match &p.ty {
TypeRef::Named(n) => Some(n.as_str()),
TypeRef::Optional(inner) => {
if let TypeRef::Named(n) = inner.as_ref() {
Some(n.as_str())
} else {
None
}
}
_ => None,
};
named.map(|n| b.type_alias.as_deref() == Some(n)).unwrap_or(false)
}
})
});
if has_visitor_bridge {
let with_visitor_params: Vec<String> = func
.params
.iter()
.map(|p| format!("_{}", p.name.to_snake_case()))
.collect();
last_was_multiline = write_nif_stub(
&mut out,
&format!("{fn_name}_with_visitor"),
&with_visitor_params,
last_was_multiline,
);
}
let has_options_field_bridge = config.trait_bridges.iter().any(|b| {
b.bind_via == crate::core::config::BridgeBinding::OptionsField
&& func.params.iter().any(|p| {
let type_name = match &p.ty {
TypeRef::Named(n) => Some(n.as_str()),
TypeRef::Optional(inner) => {
if let TypeRef::Named(n) = inner.as_ref() {
Some(n.as_str())
} else {
None
}
}
_ => None,
};
type_name.is_some_and(|n| b.options_type.as_deref() == Some(n))
})
});
if has_options_field_bridge {
let mut with_visitor_params: Vec<String> = func
.params
.iter()
.map(|p| format!("_{}", p.name.to_snake_case()))
.collect();
with_visitor_params.push("_visitor".to_string());
last_was_multiline = write_nif_stub(
&mut out,
&format!("{fn_name}_with_visitor"),
&with_visitor_params,
last_was_multiline,
);
}
}
if !config.trait_bridges.is_empty() {
last_was_multiline = write_nif_stub(
&mut out,
"visitor_reply",
&["_ref_id".to_string(), "_result".to_string()],
last_was_multiline,
);
last_was_multiline = write_nif_stub(
&mut out,
"complete_trait_call",
&["_reply_id".to_string(), "_result_json".to_string()],
last_was_multiline,
);
last_was_multiline = write_nif_stub(
&mut out,
"fail_trait_call",
&["_reply_id".to_string(), "_error_message".to_string()],
last_was_multiline,
);
}
for bridge in &config.trait_bridges {
if bridge.exclude_languages.contains(&"elixir".to_string()) {
continue;
}
if let Some(register_fn) = &bridge.register_fn {
let params = vec!["_pid".to_string(), "_name".to_string()];
last_was_multiline = write_nif_stub(&mut out, register_fn, ¶ms, last_was_multiline);
}
if let Some(unregister_fn) = &bridge.unregister_fn {
let params = vec!["_name".to_string()];
last_was_multiline = write_nif_stub(&mut out, unregister_fn, ¶ms, last_was_multiline);
}
if let Some(clear_fn) = &bridge.clear_fn {
let params = vec![];
last_was_multiline = write_nif_stub(&mut out, clear_fn, ¶ms, last_was_multiline);
}
}
let streaming_method_keys: AHashSet<String> = config
.adapters
.iter()
.filter(|a| matches!(a.pattern, crate::core::config::AdapterPattern::Streaming))
.filter_map(|a| a.owner_type.as_deref().map(|owner| format!("{owner}.{}", a.name)))
.collect();
for typ in api
.types
.iter()
.filter(|typ| !typ.is_trait && !exclude_types.contains(typ.name.as_str()))
{
for method in typ
.methods
.iter()
.filter(|m| !exclude_functions.contains(m.name.as_str()))
.filter(|m| !streaming_method_keys.contains(&format!("{}.{}", typ.name, m.name)))
{
let nif_fn_name = if method.is_async {
format!("{}_{}_async", typ.name.to_lowercase(), method.name)
} else {
format!("{}_{}", typ.name.to_lowercase(), method.name)
};
let mut underscored_params: Vec<String> = Vec::new();
if method.receiver.is_some() {
underscored_params.push("_obj".to_string());
}
for p in &method.params {
underscored_params.push(format!("_{}", elixir_safe_param_name(&p.name)));
}
if write_nif_doc(&mut out, &method.doc, last_was_multiline) {
last_was_multiline = true;
}
last_was_multiline = write_nif_stub(&mut out, &nif_fn_name, &underscored_params, last_was_multiline);
}
}
for adapter in config
.adapters
.iter()
.filter(|a| matches!(a.pattern, crate::core::config::AdapterPattern::Streaming))
{
let Some(owner) = adapter.owner_type.as_deref() else {
continue;
};
let owner_lc = owner.to_lowercase();
let start_fn = format!("{owner_lc}_{}_start", adapter.name);
let next_fn = format!("{owner_lc}_{}_next", adapter.name);
let mut start_params = vec!["_obj".to_string()];
for p in &adapter.params {
start_params.push(format!("_{}", elixir_safe_param_name(&p.name)));
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(" @doc false\n");
let _ = write_nif_stub(&mut out, &start_fn, &start_params, false);
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(" @doc false\n");
let _ = write_nif_stub(&mut out, &next_fn, &["_handle".to_string()], false);
}
let nif_wrapped_types = collect_types_for_nif_derives(api, exclude_types);
for typ in api.types.iter().filter(|t| {
!t.is_trait
&& !t.is_opaque
&& !t.fields.is_empty()
&& t.has_serde
&& !exclude_types.contains(t.name.as_str())
&& nif_wrapped_types.contains(&t.name)
}) {
let from_json_fn_name = format!("{}_from_json", typ.name.to_snake_case());
let params = vec!["_json".to_string()];
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(" @doc false\n");
let _ = write_nif_stub(&mut out, &from_json_fn_name, ¶ms, false);
}
for error in &api.errors {
for method in error.methods.iter().filter(|m| !m.sanitized) {
let nif_fn_name = format!("{}_{}", error.name.to_lowercase(), method.name);
let params = vec!["_msg".to_string()];
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(" @doc false\n");
let _ = write_nif_stub(&mut out, &nif_fn_name, ¶ms, false);
}
}
out.push_str(&template_env::render(
"native_module_footer.jinja",
minijinja::context! {},
));
out
}
fn write_nif_doc(out: &mut String, doc: &str, _prev_was_multiline: bool) -> bool {
if doc.is_empty() {
return false;
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
if !doc.contains('\n') {
let escaped = doc.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(" @doc \"");
out.push_str(&escaped);
out.push_str("\"\n");
} else {
out.push_str(" @doc \"\"\"\n");
for line in doc.lines() {
let safe = line.replace("\"\"\"", "\"\" \"");
if safe.is_empty() {
out.push('\n');
} else {
out.push_str(" ");
out.push_str(&safe);
out.push('\n');
}
}
out.push_str(" \"\"\"\n");
}
true
}
fn write_nif_stub(out: &mut String, fn_name: &str, params: &[String], prev_was_multiline: bool) -> bool {
let args = params.join(", ");
let sig = if args.is_empty() {
fn_name.to_string()
} else {
format!("{fn_name}({args})")
};
let single_line_len = 6 + sig.len() + 40;
if single_line_len > 120 {
let ctx = minijinja::context! { sig => sig, prev_was_multiline => prev_was_multiline };
out.push_str(&template_env::render("nif_stub_multi_line.jinja", ctx));
true
} else {
let ctx = minijinja::context! { sig => sig };
out.push_str(&template_env::render("nif_stub_single_line.jinja", ctx));
false
}
}
pub(super) fn gen_elixir_struct_module(
typ: &TypeDef,
app_module: &str,
enum_defaults: &HashMap<String, String>,
opaque_types: &AHashSet<String>,
) -> String {
let mut out = String::with_capacity(512);
out.push_str(&hash::header(CommentStyle::Hash));
let ctx = minijinja::context! {
app_module => app_module,
type_name => &typ.name,
};
out.push_str(&template_env::render("struct_module_header.jinja", ctx));
if !typ.doc.is_empty() {
emit_elixir_doc_attr(&mut out, "moduledoc", &typ.doc, " ");
} else {
out.push_str(" @moduledoc false\n");
}
out.push('\n');
let default_types: AHashSet<String> = enum_defaults.keys().cloned().collect();
if !typ.doc.is_empty() {
let first_para = doc_first_paragraph_joined(&typ.doc);
emit_elixir_doc_attr(&mut out, "typedoc", &first_para, " ");
}
out.push_str(" @type t :: %__MODULE__{\n");
let fields: Vec<_> = binding_fields(&typ.fields).collect();
if !fields.is_empty() {
for (i, field) in fields.iter().enumerate() {
let field_name = field.name.to_snake_case();
let field_type = elixir_typespec(&field.ty, opaque_types, &default_types);
let field_defaults_to_nil = matches!(
field.ty,
TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json
);
let field_type_with_optional =
if (field.optional || field_defaults_to_nil) && !matches!(field.ty, TypeRef::Optional(_)) {
format!("{field_type} | nil")
} else {
field_type
};
if i == fields.len() - 1 {
out.push_str(&format!(" {field_name}: {field_type_with_optional}\n"));
} else {
out.push_str(&format!(" {field_name}: {field_type_with_optional},\n"));
}
}
}
out.push_str(" }\n\n");
if fields.is_empty() {
out.push_str(&template_env::render("struct_empty.jinja", minijinja::context! {}));
} else {
out.push_str(" defstruct ");
for (i, field) in fields.iter().enumerate() {
let default = elixir_field_default(field, &field.ty, enum_defaults, opaque_types);
let name = field.name.to_snake_case();
if i == 0 {
out.push_str(&template_env::render(
"elixir_enum_field_first.jinja",
minijinja::context! {
name => &name,
default => &default,
},
));
} else {
out.push_str(&template_env::render(
"elixir_enum_field_rest.jinja",
minijinja::context! {
name => &name,
default => &default,
},
));
}
}
out.push('\n');
}
if typ.has_default {
out.push('\n');
out.push_str(" defimpl Jason.Encoder do\n");
out.push_str(" @doc false\n");
out.push_str(" def encode(value, opts) do\n");
out.push_str(" value\n");
out.push_str(" |> Map.from_struct()\n");
out.push_str(" |> Enum.reject(fn {_k, v} -> v == nil end)\n");
out.push_str(" |> Enum.into(%{})\n");
out.push_str(" |> Jason.Encoder.encode(opts)\n");
out.push_str(" end\n");
out.push_str(" end\n");
}
if typ.name == "HeaderMetadata" {
out.push('\n');
out.push_str(" @doc \"Validate that the header level is within valid range (1-6).\"\n");
out.push_str(" @spec valid?(t()) :: boolean()\n");
out.push_str(" def valid?(%__MODULE__{level: level}) do\n");
out.push_str(" level >= 1 and level <= 6\n");
out.push_str(" end\n");
}
while out.ends_with("\n\n") {
out.pop();
}
out.push_str(&template_env::render(
"struct_module_footer.jinja",
minijinja::context! {},
));
out
}
pub(super) fn gen_elixir_opaque_module(typ: &TypeDef, app_module: &str, config: &ResolvedCrateConfig) -> String {
let mut out = String::with_capacity(512);
out.push_str(&hash::header(CommentStyle::Hash));
let ctx = minijinja::context! {
app_module => app_module,
type_name => &typ.name,
};
out.push_str(&template_env::render("struct_module_header.jinja", ctx));
if !typ.doc.is_empty() {
emit_elixir_doc_attr(&mut out, "moduledoc", &typ.doc, " ");
} else {
out.push_str(" @moduledoc false\n");
}
out.push('\n');
let needs_native_alias = typ.has_default || !typ.methods.is_empty();
if needs_native_alias {
out.push_str(&format!(" alias {app_module}.Native\n\n"));
}
out.push_str(" defstruct [:ref]\n\n");
if !typ.doc.is_empty() {
let first_para = doc_first_paragraph_joined(&typ.doc);
emit_elixir_doc_attr(&mut out, "typedoc", &first_para, " ");
}
out.push_str(" @type t :: %__MODULE__{ref: reference()}\n\n");
let type_lower = typ.name.to_lowercase();
let streaming_method_names: AHashSet<String> = config
.adapters
.iter()
.filter(|a| matches!(a.pattern, crate::core::config::AdapterPattern::Streaming))
.filter(|a| a.owner_type.as_deref() == Some(typ.name.as_str()))
.map(|a| a.name.clone())
.collect();
if typ.has_default {
out.push_str(" @doc \"Build a default instance.\"\n");
out.push_str(" @spec new() :: t()\n");
out.push_str(" def new do\n");
out.push_str(&format!(" %__MODULE__{{ref: Native.{type_lower}_default()}}\n"));
out.push_str(" end\n\n");
}
for method in &typ.methods {
let method_name = method.name.to_snake_case();
if streaming_method_names.contains(&method.name) {
let start_fn = format!("{type_lower}_{}_start", method.name);
let next_fn = format!("{type_lower}_{}_next", method.name);
let mut def_args: Vec<String> = Vec::new();
let mut start_call_args: Vec<String> = Vec::new();
if method.receiver.is_some() {
def_args.push("obj".to_string());
start_call_args.push("obj.ref".to_string());
}
for p in &method.params {
let safe = elixir_safe_param_name(&p.name);
def_args.push(safe.clone());
start_call_args.push(safe);
}
let doc_first = method.doc.lines().next().unwrap_or("").replace('"', "\\\"");
if !doc_first.is_empty() {
out.push_str(&format!(" @doc \"{doc_first}\"\n"));
}
out.push_str(&format!(" def {method_name}({}) do\n", def_args.join(", ")));
out.push_str(&format!(
" case Native.{start_fn}({}) do\n",
start_call_args.join(", ")
));
out.push_str(" {:ok, handle} ->\n");
out.push_str(" stream =\n");
out.push_str(" Stream.unfold(handle, fn h ->\n");
out.push_str(&format!(" case Native.{next_fn}(h) do\n"));
out.push_str(" {:ok, nil} ->\n");
out.push_str(" nil\n");
out.push('\n');
out.push_str(" {:ok, chunk_json} when is_binary(chunk_json) ->\n");
out.push_str(" {Jason.decode!(chunk_json, keys: :atoms), h}\n");
out.push('\n');
out.push_str(" {:ok, chunk} ->\n");
out.push_str(" {chunk, h}\n");
out.push('\n');
out.push_str(" {:error, _} ->\n");
out.push_str(" nil\n");
out.push_str(" end\n");
out.push_str(" end)\n");
out.push('\n');
out.push_str(" {:ok, stream}\n");
out.push('\n');
out.push_str(" {:error, reason} ->\n");
out.push_str(" {:error, reason}\n");
out.push_str(" end\n");
out.push_str(" end\n\n");
continue;
}
let nif_fn = if method.is_async {
if method.name.ends_with("_async") {
format!("{type_lower}_{}", method.name)
} else {
format!("{type_lower}_{}_async", method.name)
}
} else {
format!("{type_lower}_{}", method.name)
};
let mut call_args: Vec<String> = Vec::new();
let mut def_args: Vec<String> = Vec::new();
if method.receiver.is_some() {
def_args.push("obj".to_string());
call_args.push("obj.ref".to_string());
}
for p in &method.params {
let safe = elixir_safe_param_name(&p.name);
def_args.push(safe.clone());
call_args.push(safe);
}
let doc_first = method.doc.lines().next().unwrap_or("").replace('"', "\\\"");
if !doc_first.is_empty() {
out.push_str(&format!(" @doc \"{doc_first}\"\n"));
}
out.push_str(&format!(" def {method_name}({}) do\n", def_args.join(", ")));
out.push_str(&format!(" Native.{nif_fn}({})\n", call_args.join(", ")));
out.push_str(" end\n\n");
}
while out.ends_with("\n\n") {
out.pop();
}
out.push_str(&template_env::render(
"struct_module_footer.jinja",
minijinja::context! {},
));
out
}
const ELIXIR_BUILTIN_TYPES: &[&str] = &[
"any",
"as_boolean",
"atom",
"binary",
"boolean",
"byte",
"char",
"charlist",
"float",
"fun",
"function",
"identifier",
"integer",
"iodata",
"iolist",
"keyword",
"list",
"map",
"mfa",
"module",
"no_return",
"node",
"none",
"number",
"pid",
"port",
"reference",
"string",
"struct",
"term",
"timeout",
"tuple",
];
pub(super) fn elixir_safe_type_name(name: &str) -> String {
if ELIXIR_BUILTIN_TYPES.contains(&name) {
format!("{name}_variant")
} else {
name.to_owned()
}
}
const ELIXIR_RESERVED_MODULE_ATTRIBUTES: &[&str] = &[
"after_compile",
"before_compile",
"behaviour",
"callback",
"compile",
"deprecated",
"derive",
"dialyzer",
"doc",
"enforce_keys",
"external_resource",
"file",
"impl",
"moduledoc",
"on_definition",
"on_load",
"opaque",
"optional_callbacks",
"spec",
"type",
"typedoc",
"typep",
"vsn",
];
pub(super) fn elixir_safe_attr_name(name: &str) -> String {
if ELIXIR_RESERVED_MODULE_ATTRIBUTES.contains(&name) {
format!("{name}_attr")
} else {
name.to_owned()
}
}
const ELIXIR_RESERVED_WORDS: &[&str] = &[
"after", "and", "catch", "cond", "do", "else", "end", "false", "fn", "for", "if", "in", "nil", "not", "or",
"raise", "receive", "rescue", "true", "try", "unless", "when", "with",
];
pub(super) fn elixir_safe_param_name(name: &str) -> String {
let snake = name.to_snake_case();
if ELIXIR_RESERVED_WORDS.contains(&snake.as_str()) {
format!("{snake}_val")
} else {
snake
}
}
pub(super) fn elixir_safe_atom(atom_value: &str) -> String {
fn is_valid_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return false;
}
loop {
match chars.next() {
None => return true,
Some(c) => {
if !c.is_ascii_alphanumeric() && c != '_' && c != '?' && c != '!' {
return false;
}
if (c == '?' || c == '!') && chars.as_str() != "" {
return false;
}
}
}
}
}
if is_valid_identifier(atom_value) {
atom_value.to_string()
} else {
format!(r#""{atom_value}""#)
}
}
#[allow(dead_code)]
pub(super) fn gen_elixir_enum_module(enum_def: &crate::core::ir::EnumDef, app_module: &str) -> String {
gen_elixir_enum_module_with_known_types(enum_def, app_module, &AHashSet::new())
}
pub(super) fn gen_elixir_enum_module_with_known_types(
enum_def: &crate::core::ir::EnumDef,
app_module: &str,
known_types: &AHashSet<String>,
) -> String {
let mut out = String::with_capacity(256);
out.push_str(&hash::header(CommentStyle::Hash));
let ctx = minijinja::context! {
app_module => app_module,
enum_name => &enum_def.name,
};
out.push_str(&template_env::render("enum_module_header.jinja", ctx));
if !enum_def.doc.is_empty() {
emit_elixir_doc_attr(&mut out, "moduledoc", &enum_def.doc, " ");
} else {
out.push_str(" @moduledoc false\n");
}
out.push('\n');
let is_simple = enum_def.variants.iter().all(|v| v.fields.is_empty());
if is_simple {
let atom_arms: Vec<String> = enum_def
.variants
.iter()
.map(|v| {
let atom = v
.serde_rename
.clone()
.unwrap_or_else(|| crate::codegen::naming::pascal_to_snake(&v.name));
format!(":{}", elixir_safe_atom(&atom))
})
.collect();
if !enum_def.doc.is_empty() {
let first_para = doc_first_paragraph_joined(&enum_def.doc);
emit_elixir_doc_attr(&mut out, "typedoc", &first_para, " ");
}
let single_line = format!(" @type t :: {}", atom_arms.join(" | "));
if single_line.len() <= 120 {
out.push_str(&template_env::render(
"elixir_enum_type_single_line.jinja",
minijinja::context! {
arms => &atom_arms.join(" | "),
},
));
} else {
out.push_str(" @type t ::\n");
for (i, arm) in atom_arms.iter().enumerate() {
if i == 0 {
out.push_str(&template_env::render(
"elixir_enum_type_arm_first.jinja",
minijinja::context! {
arm => arm,
},
));
} else {
out.push_str(&template_env::render(
"elixir_enum_type_arm_rest.jinja",
minijinja::context! {
arm => arm,
},
));
}
}
}
out.push('\n');
for variant in &enum_def.variants {
let attr_name = elixir_safe_attr_name(&crate::codegen::naming::pascal_to_snake(&variant.name));
let atom_value = variant
.serde_rename
.clone()
.unwrap_or_else(|| crate::codegen::naming::pascal_to_snake(&variant.name));
let atom_literal = elixir_safe_atom(&atom_value);
out.push_str(&template_env::render(
"elixir_enum_attr.jinja",
minijinja::context! {
attr_name => &attr_name,
atom_name => &atom_literal,
},
));
}
out.push('\n');
for variant in &enum_def.variants {
let fn_name = crate::codegen::naming::pascal_to_snake(&variant.name);
let attr_name = elixir_safe_attr_name(&fn_name);
if !variant.doc.is_empty() {
let first_para = doc_first_paragraph_joined(&variant.doc);
emit_elixir_doc_attr(&mut out, "doc", &first_para, " ");
}
out.push_str(&template_env::render(
"elixir_enum_accessor.jinja",
minijinja::context! {
atom_name => &fn_name,
attr_name => &attr_name,
},
));
}
} else {
if !enum_def.doc.is_empty() {
let first_para = doc_first_paragraph_joined(&enum_def.doc);
emit_elixir_doc_attr(&mut out, "typedoc", &first_para, " ");
}
out.push_str(" @type t :: term()\n");
out.push('\n');
for variant in &enum_def.variants {
let variant_atom = format!(":{}", crate::codegen::naming::pascal_to_snake(&variant.name));
let type_name = elixir_safe_type_name(&crate::codegen::naming::pascal_to_snake(&variant.name));
if !variant.doc.is_empty() {
let first_para = doc_first_paragraph_joined(&variant.doc);
emit_elixir_doc_attr(&mut out, "typedoc", &first_para, " ");
}
if variant.fields.is_empty() {
out.push_str(&template_env::render(
"elixir_data_enum_unit_type.jinja",
minijinja::context! {
type_name => &type_name,
variant_atom => &variant_atom,
},
));
} else {
let field_types: Vec<String> = variant
.fields
.iter()
.enumerate()
.map(|(idx, f)| {
let type_name = match &f.ty {
TypeRef::Named(n) => Some(n.as_str()),
TypeRef::String => Some("String"),
TypeRef::Bytes => Some("bytes"),
TypeRef::Char => Some("char"),
TypeRef::Path => Some("path"),
TypeRef::Json => Some("json"),
TypeRef::Primitive(p) => match p {
crate::core::ir::PrimitiveType::Bool => Some("bool"),
crate::core::ir::PrimitiveType::U8 => Some("u8"),
crate::core::ir::PrimitiveType::U16 => Some("u16"),
crate::core::ir::PrimitiveType::U32 => Some("u32"),
crate::core::ir::PrimitiveType::U64 => Some("u64"),
crate::core::ir::PrimitiveType::Usize => Some("usize"),
crate::core::ir::PrimitiveType::I8 => Some("i8"),
crate::core::ir::PrimitiveType::I16 => Some("i16"),
crate::core::ir::PrimitiveType::I32 => Some("i32"),
crate::core::ir::PrimitiveType::I64 => Some("i64"),
crate::core::ir::PrimitiveType::Isize => Some("isize"),
crate::core::ir::PrimitiveType::F32 => Some("f32"),
crate::core::ir::PrimitiveType::F64 => Some("f64"),
},
_ => None,
};
let field_name =
elixir_field_name_with_type(&f.name, idx, type_name, &variant.name, variant.fields.len());
let field_type = if let TypeRef::Named(n) = &f.ty {
if known_types.contains(n) {
format!("{app_module}.{}.t()", n)
} else {
let opaque_types = AHashSet::new();
let default_types = AHashSet::new();
elixir_typespec(&f.ty, &opaque_types, &default_types)
}
} else {
let opaque_types = AHashSet::new();
let default_types = AHashSet::new();
elixir_typespec(&f.ty, &opaque_types, &default_types)
};
format!("{field_name}: {field_type}")
})
.collect();
out.push_str(&template_env::render(
"elixir_data_enum_struct_type.jinja",
minijinja::context! {
type_name => &type_name,
variant_atom => &variant_atom,
field_types => field_types.join(", "),
},
));
}
}
}
out.push_str(&template_env::render(
"enum_module_footer.jinja",
minijinja::context! {},
));
out
}
fn elixir_field_name_with_type(
field_name: &str,
field_idx: usize,
field_type_name: Option<&str>,
variant_name: &str,
total_fields: usize,
) -> String {
let stripped = field_name.trim_start_matches('_');
if !stripped.is_empty() && !stripped.chars().all(|c| c.is_ascii_digit()) {
return stripped.to_snake_case();
}
if total_fields == 1 {
if let Some(type_name) = field_type_name {
if let Some(remainder) = type_name.strip_prefix(variant_name) {
let derived = remainder.to_snake_case();
if !derived.is_empty() {
return derived;
}
}
if is_primitive_type(type_name) {
return "value".to_string();
}
}
}
if total_fields > 1 {
return format!("value{}", field_idx);
}
"value".to_string()
}
fn is_primitive_type(type_name: &str) -> bool {
matches!(
type_name,
"String"
| "bool"
| "u8"
| "u16"
| "u32"
| "u64"
| "usize"
| "i8"
| "i16"
| "i32"
| "i64"
| "isize"
| "f32"
| "f64"
| "char"
| "byte"
| "unit"
)
}
fn elixir_format_integer(n: i64) -> String {
let (neg, s) = if n < 0 {
(true, (-n).to_string())
} else {
(false, n.to_string())
};
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push('_');
}
result.push(c);
}
let formatted: String = result.chars().rev().collect();
if neg { format!("-{formatted}") } else { formatted }
}
fn elixir_field_default(
field: &FieldDef,
ty: &TypeRef,
enum_defaults: &HashMap<String, String>,
_opaque_types: &AHashSet<String>,
) -> String {
use crate::core::ir::DefaultValue;
let is_nilable = field.optional || matches!(ty, TypeRef::Optional(_));
if is_nilable {
return "nil".to_string();
}
if let Some(td) = &field.typed_default {
return match td {
DefaultValue::BoolLiteral(b) => (if *b { "true" } else { "false" }).to_string(),
DefaultValue::StringLiteral(s) => format!("\"{}\"", s.replace('"', "\\\"")),
DefaultValue::IntLiteral(i) => elixir_format_integer(*i),
DefaultValue::FloatLiteral(f) => format!("{f}"),
DefaultValue::EnumVariant(v) => format!(":{}", v.to_snake_case()),
DefaultValue::Empty => elixir_zero_value(ty, enum_defaults),
DefaultValue::None => "nil".to_string(),
};
}
elixir_zero_value(ty, enum_defaults)
}
fn elixir_zero_value(ty: &TypeRef, enum_defaults: &HashMap<String, String>) -> String {
match ty {
TypeRef::Primitive(p) => match p {
crate::core::ir::PrimitiveType::Bool => "false".to_string(),
crate::core::ir::PrimitiveType::F32 | crate::core::ir::PrimitiveType::F64 => "0.0".to_string(),
_ => "0".to_string(),
},
TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json => "nil".to_string(),
TypeRef::Bytes => "<<>>".to_string(),
TypeRef::Duration => "0".to_string(),
TypeRef::Vec(_) => "[]".to_string(),
TypeRef::Map(_, _) => "%{}".to_string(),
TypeRef::Optional(_) => "nil".to_string(),
TypeRef::Unit => "nil".to_string(),
TypeRef::Named(name) => {
if let Some(variant) = enum_defaults.get(name) {
format!(":{variant}")
} else {
"nil".to_string()
}
}
}
}
pub(super) fn elixir_typespec(
ty: &TypeRef,
opaque_types: &AHashSet<String>,
default_types: &AHashSet<String>,
) -> String {
match ty {
TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json => "String.t()".to_string(),
TypeRef::Bytes => "binary()".to_string(),
TypeRef::Unit => "nil".to_string(),
TypeRef::Duration => "non_neg_integer()".to_string(),
TypeRef::Primitive(p) => match p {
crate::core::ir::PrimitiveType::Bool => "boolean()".to_string(),
crate::core::ir::PrimitiveType::F32 | crate::core::ir::PrimitiveType::F64 => "float()".to_string(),
crate::core::ir::PrimitiveType::U8
| crate::core::ir::PrimitiveType::U16
| crate::core::ir::PrimitiveType::U32
| crate::core::ir::PrimitiveType::U64
| crate::core::ir::PrimitiveType::Usize => "non_neg_integer()".to_string(),
crate::core::ir::PrimitiveType::I8
| crate::core::ir::PrimitiveType::I16
| crate::core::ir::PrimitiveType::I32
| crate::core::ir::PrimitiveType::I64
| crate::core::ir::PrimitiveType::Isize => "integer()".to_string(),
},
TypeRef::Named(name) => {
if opaque_types.contains(name) {
"reference()".to_string()
} else if default_types.contains(name) {
"String.t() | nil".to_string()
} else {
"map()".to_string()
}
}
TypeRef::Optional(inner) => {
format!("{} | nil", elixir_typespec(inner, opaque_types, default_types))
}
TypeRef::Vec(inner) => {
format!("[{}]", elixir_typespec(inner, opaque_types, default_types))
}
TypeRef::Map(_, _) => "map()".to_string(),
}
}
pub(super) fn elixir_return_typespec(
ty: &TypeRef,
has_error: bool,
opaque_types: &AHashSet<String>,
default_types: &AHashSet<String>,
) -> String {
let base = match ty {
TypeRef::Named(name) if default_types.contains(name) => "map()".to_string(),
TypeRef::Optional(inner) => match inner.as_ref() {
TypeRef::Named(name) if default_types.contains(name) => "map() | nil".to_string(),
_ => elixir_typespec(ty, opaque_types, default_types),
},
_ => elixir_typespec(ty, opaque_types, default_types),
};
if has_error {
format!("{{:ok, {}}} | {{:error, atom, String.t()}}", base)
} else {
base
}
}
pub(super) fn collect_types_for_nif_derives(
api: &crate::core::ir::ApiSurface,
exclude_types: &AHashSet<&str>,
) -> AHashSet<String> {
let mut types = AHashSet::new();
for func in &api.functions {
collect_named_types_from_ref(&func.return_type, &mut types);
for param in &func.params {
collect_named_types_from_ref(¶m.ty, &mut types);
}
}
for typ in api.types.iter().filter(|t| !t.is_trait) {
for method in &typ.methods {
collect_named_types_from_ref(&method.return_type, &mut types);
for param in &method.params {
collect_named_types_from_ref(¶m.ty, &mut types);
}
}
}
for enum_def in &api.enums {
for variant in &enum_def.variants {
for field in &variant.fields {
collect_named_types_from_ref(&field.ty, &mut types);
}
}
}
let mut changed = true;
while changed {
changed = false;
let snapshot: Vec<String> = types.iter().cloned().collect();
for type_name in &snapshot {
if let Some(typ) = api.types.iter().find(|t| t.name == *type_name) {
for field in binding_fields(&typ.fields) {
if collect_named_types_from_ref(&field.ty, &mut types) {
changed = true;
}
}
}
}
}
types.retain(|name| {
!exclude_types.contains(name.as_str()) && !api.types.iter().any(|t| t.name == *name && t.is_opaque)
});
types
}
fn collect_named_types_from_ref(ty: &TypeRef, out: &mut AHashSet<String>) -> bool {
match ty {
TypeRef::Named(name) => out.insert(name.clone()),
TypeRef::Optional(inner) => collect_named_types_from_ref(inner, out),
TypeRef::Vec(inner) => collect_named_types_from_ref(inner, out),
TypeRef::Map(k, v) => {
let k_added = collect_named_types_from_ref(k, out);
let v_added = collect_named_types_from_ref(v, out);
k_added || v_added
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ir::{EnumDef, EnumVariant, FieldDef};
#[test]
fn test_elixir_field_name_with_type_payload_derived() {
let name = elixir_field_name_with_type("_0", 0, Some("PdfMetadata"), "Pdf", 1);
assert_eq!(name, "metadata");
let name = elixir_field_name_with_type("_0", 0, Some("ExcelMetadata"), "Excel", 1);
assert_eq!(name, "metadata");
let name = elixir_field_name_with_type("_0", 0, Some("DocxMetadata"), "Docx", 1);
assert_eq!(name, "metadata");
}
#[test]
fn test_elixir_field_name_with_type_primitive() {
let name = elixir_field_name_with_type("_0", 0, Some("String"), "Error", 1);
assert_eq!(name, "value");
let name = elixir_field_name_with_type("_0", 0, Some("bool"), "Flag", 1);
assert_eq!(name, "value");
}
#[test]
fn test_elixir_field_name_with_type_multiple_fields() {
let name = elixir_field_name_with_type("_0", 0, None, "Pair", 2);
assert_eq!(name, "value0");
let name = elixir_field_name_with_type("_1", 1, None, "Pair", 2);
assert_eq!(name, "value1");
}
#[test]
fn test_elixir_field_name_with_type_named_field() {
let name = elixir_field_name_with_type("reason", 0, Some("String"), "Error", 1);
assert_eq!(name, "reason");
}
#[test]
fn test_gen_elixir_enum_module_data_enum_with_payload_derived_names() {
let format_enum = EnumDef {
name: "FormatMetadata".to_string(),
rust_path: "my_crate::FormatMetadata".to_string(),
original_rust_path: String::new(),
variants: vec![
EnumVariant {
name: "Pdf".into(),
fields: vec![FieldDef {
name: "_0".into(),
ty: TypeRef::Named("PdfMetadata".into()),
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: crate::core::ir::CoreWrapper::None,
vec_inner_core_wrapper: crate::core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}],
is_tuple: true,
doc: String::new(),
is_default: false,
serde_rename: None,
},
EnumVariant {
name: "Docx".into(),
fields: vec![FieldDef {
name: "_0".into(),
ty: TypeRef::Named("DocxMetadata".into()),
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: crate::core::ir::CoreWrapper::None,
vec_inner_core_wrapper: crate::core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}],
is_tuple: true,
doc: String::new(),
is_default: false,
serde_rename: None,
},
],
doc: String::new(),
cfg: None,
is_copy: false,
has_serde: false,
serde_tag: None,
serde_untagged: false,
serde_rename_all: None,
binding_excluded: false,
binding_exclusion_reason: None,
};
let result = gen_elixir_enum_module(&format_enum, "SampleCrate");
assert!(
result.contains("@type pdf :: %{type: :pdf, metadata: map()}"),
"should use payload-derived 'metadata' field name with concrete type map(); got:\n{result}"
);
assert!(
result.contains("@type docx :: %{type: :docx, metadata: map()}"),
"should use payload-derived 'metadata' field name with concrete type map(); got:\n{result}"
);
assert!(
!result.contains("value_0: term()"),
"should not use generic value_0 field name with term() type; got:\n{result}"
);
}
#[test]
fn test_elixir_safe_atom_valid_identifier() {
assert_eq!(elixir_safe_atom("img"), "img");
assert_eq!(elixir_safe_atom("picture_source"), "picture_source");
assert_eq!(elixir_safe_atom("valid?"), "valid?");
assert_eq!(elixir_safe_atom("valid!"), "valid!");
}
#[test]
fn test_elixir_safe_atom_with_special_chars() {
assert_eq!(elixir_safe_atom("og:image"), r#""og:image""#);
assert_eq!(elixir_safe_atom("twitter:image"), r#""twitter:image""#);
assert_eq!(elixir_safe_atom("some-value"), r#""some-value""#);
}
#[test]
fn test_gen_elixir_enum_module_with_serde_rename_special_chars() {
let image_source_enum = EnumDef {
name: "ImageSource".to_string(),
rust_path: "my_crate::ImageSource".to_string(),
original_rust_path: String::new(),
variants: vec![
EnumVariant {
name: "Img".into(),
fields: vec![],
is_tuple: false,
doc: String::new(),
is_default: false,
serde_rename: None,
},
EnumVariant {
name: "OgImage".into(),
fields: vec![],
is_tuple: false,
doc: String::new(),
is_default: false,
serde_rename: Some("og:image".to_string()),
},
EnumVariant {
name: "TwitterImage".into(),
fields: vec![],
is_tuple: false,
doc: String::new(),
is_default: false,
serde_rename: Some("twitter:image".to_string()),
},
],
doc: String::new(),
cfg: None,
is_copy: false,
has_serde: false,
serde_tag: None,
serde_untagged: false,
serde_rename_all: None,
binding_excluded: false,
binding_exclusion_reason: None,
};
let result = gen_elixir_enum_module(&image_source_enum, "SampleCrawler");
assert!(
result.contains(":img | :\"og:image\" | :\"twitter:image\""),
"should emit quoted atoms in @type for serde_rename with colons; got:\n{result}"
);
assert!(
result.contains("@og_image "),
"should use @og_image attribute name (from variant OgImage), not @og:image; got:\n{result}"
);
assert!(
result.contains("@twitter_image "),
"should use @twitter_image attribute name (from variant TwitterImage), not @twitter:image; got:\n{result}"
);
assert!(
result.contains("def og_image, do: @og_image"),
"should emit def og_image() function name, not def og:image(); got:\n{result}"
);
assert!(
result.contains("def twitter_image, do: @twitter_image"),
"should emit def twitter_image() function name, not def twitter:image(); got:\n{result}"
);
assert!(
result.contains(r#"@og_image :"og:image""#),
"should emit @og_image with quoted atom value; got:\n{result}"
);
assert!(
result.contains(r#"@twitter_image :"twitter:image""#),
"should emit @twitter_image with quoted atom value; got:\n{result}"
);
}
#[test]
fn test_gen_elixir_enum_module_resolves_known_payload_types() {
let format_enum = EnumDef {
name: "FormatMetadata".to_string(),
rust_path: "my_crate::FormatMetadata".to_string(),
original_rust_path: String::new(),
variants: vec![
EnumVariant {
name: "Pdf".into(),
fields: vec![FieldDef {
name: "_0".into(),
ty: TypeRef::Named("PdfMetadata".into()),
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: crate::core::ir::CoreWrapper::None,
vec_inner_core_wrapper: crate::core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}],
is_tuple: true,
doc: String::new(),
is_default: false,
serde_rename: None,
},
EnumVariant {
name: "Other".into(),
fields: vec![FieldDef {
name: "_0".into(),
ty: TypeRef::Named("UnknownType".into()),
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: crate::core::ir::CoreWrapper::None,
vec_inner_core_wrapper: crate::core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}],
is_tuple: true,
doc: String::new(),
is_default: false,
serde_rename: None,
},
],
doc: String::new(),
cfg: None,
is_copy: false,
has_serde: false,
serde_tag: None,
serde_untagged: false,
serde_rename_all: None,
binding_excluded: false,
binding_exclusion_reason: None,
};
let mut known_types = AHashSet::new();
known_types.insert("PdfMetadata".to_string());
let result = gen_elixir_enum_module_with_known_types(&format_enum, "SampleCrate", &known_types);
assert!(
result.contains("SampleCrate.PdfMetadata.t()"),
"should resolve PdfMetadata to SampleCrate.PdfMetadata.t(); got:\n{result}"
);
assert!(
result.contains("value: map()"),
"should fall back to map() for unknown type; got:\n{result}"
);
}
}