use ahash::AHashSet;
use alef_core::ir::{ErrorDef, ErrorVariant};
use crate::conversions::is_tuple_variant;
fn error_variant_wildcard_pattern(rust_path: &str, variant: &ErrorVariant) -> String {
if variant.is_unit {
format!("{rust_path}::{}", variant.name)
} else if is_tuple_variant(&variant.fields) {
format!("{rust_path}::{}(..)", variant.name)
} else {
format!("{rust_path}::{} {{ .. }}", variant.name)
}
}
const PYTHON_BUILTIN_EXCEPTIONS: &[&str] = &[
"ConnectionError",
"TimeoutError",
"PermissionError",
"FileNotFoundError",
"ValueError",
"TypeError",
"RuntimeError",
"OSError",
"IOError",
"KeyError",
"IndexError",
"AttributeError",
"ImportError",
"MemoryError",
"OverflowError",
"StopIteration",
"RecursionError",
"SystemError",
"ReferenceError",
"BufferError",
"EOFError",
"LookupError",
"ArithmeticError",
"AssertionError",
"BlockingIOError",
"BrokenPipeError",
"ChildProcessError",
"FileExistsError",
"InterruptedError",
"IsADirectoryError",
"NotADirectoryError",
"ProcessLookupError",
"UnicodeError",
];
fn error_base_prefix(error_name: &str) -> &str {
error_name.strip_suffix("Error").unwrap_or(error_name)
}
pub fn python_exception_name(variant_name: &str, error_name: &str) -> String {
let candidate = if variant_name.ends_with("Error") {
variant_name.to_string()
} else {
format!("{}Error", variant_name)
};
if PYTHON_BUILTIN_EXCEPTIONS.contains(&candidate.as_str()) {
let prefix = error_base_prefix(error_name);
if candidate.starts_with(prefix) {
candidate
} else {
format!("{}{}", prefix, candidate)
}
} else {
candidate
}
}
pub fn gen_pyo3_error_types(error: &ErrorDef, module_name: &str, seen_exceptions: &mut AHashSet<String>) -> String {
let mut variant_names = Vec::new();
for variant in &error.variants {
let variant_name = python_exception_name(&variant.name, &error.name);
if seen_exceptions.insert(variant_name.clone()) {
variant_names.push(variant_name);
}
}
let include_base = seen_exceptions.insert(error.name.clone());
crate::template_env::render(
"error_gen/pyo3_error_types.jinja",
minijinja::context! {
variant_names => variant_names,
module_name => module_name,
error_name => error.name.as_str(),
include_base => include_base,
},
)
}
pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
let normalized = error.rust_path.replace('-', "_");
let segments: Vec<&str> = normalized.split("::").collect();
if segments.len() > 2 {
let crate_name = segments[0];
let error_name = segments[segments.len() - 1];
format!("{crate_name}::{error_name}")
} else {
normalized
}
};
let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
let has_methods = !error.methods.is_empty();
let mut variants = Vec::new();
for variant in &error.variants {
let pattern = error_variant_wildcard_pattern(&rust_path, variant);
let variant_exc_name = python_exception_name(&variant.name, &error.name);
variants.push((pattern, variant_exc_name));
}
crate::template_env::render(
"error_gen/pyo3_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
error_name => error.name.as_str(),
variants => variants,
has_methods => has_methods,
},
)
}
pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
let mut registrations = Vec::with_capacity(error.variants.len() + 1);
for variant in &error.variants {
let variant_exc_name = python_exception_name(&variant.name, &error.name);
if seen_registrations.insert(variant_exc_name.clone()) {
registrations.push(format!(
" m.add(\"{}\", m.py().get_type::<{}>())?;",
variant_exc_name, variant_exc_name
));
}
}
if seen_registrations.insert(error.name.clone()) {
registrations.push(format!(
" m.add(\"{}\", m.py().get_type::<{}>())?;",
error.name, error.name
));
}
registrations
}
pub fn converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_py_err", to_snake_case(&error.name))
}
fn to_snake_case(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}
pub fn gen_napi_error_types(error: &ErrorDef) -> String {
let mut variants = Vec::new();
let error_screaming = to_screaming_snake(&error.name);
for variant in &error.variants {
let variant_const = format!("{}_ERROR_{}", error_screaming, to_screaming_snake(&variant.name));
variants.push((variant_const, variant.name.clone()));
}
crate::template_env::render(
"error_gen/napi_error_types.jinja",
minijinja::context! {
variants => variants,
},
)
}
pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));
let mut variants = Vec::new();
for variant in &error.variants {
let pattern = error_variant_wildcard_pattern(&rust_path, variant);
variants.push((pattern, variant.name.clone()));
}
crate::template_env::render(
"error_gen/napi_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
variants => variants,
},
)
}
pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_napi_err", to_snake_case(&error.name))
}
pub fn gen_wasm_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_js_value", to_snake_case(&error.name));
let code_fn_name = format!("{}_error_code", to_snake_case(&error.name));
let mut code_variants = Vec::new();
for variant in &error.variants {
let pattern = error_variant_wildcard_pattern(&rust_path, variant);
let code = to_snake_case(&variant.name);
code_variants.push((pattern, code));
}
let default_code = to_snake_case(&error.name);
let code_fn = crate::template_env::render(
"error_gen/wasm_error_code_fn.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
code_fn_name => code_fn_name.as_str(),
variants => code_variants,
default_code => default_code.as_str(),
},
);
let converter_fn = crate::template_env::render(
"error_gen/wasm_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
code_fn_name => code_fn_name.as_str(),
},
);
format!("{}\n\n{}", code_fn, converter_fn)
}
pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_js_value", to_snake_case(&error.name))
}
pub fn gen_wasm_error_methods(error: &ErrorDef, core_import: &str, wasm_prefix: &str) -> String {
if error.methods.is_empty() {
return String::new();
}
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let wasm_struct_name = format!("{wasm_prefix}{}", error.name);
let struct_def = format!(
"/// Opaque WASM handle for [`{rust_path}`] that exposes introspection methods.\n\
#[wasm_bindgen]\n\
pub struct {wasm_struct_name} {{\n\
pub(crate) inner: {rust_path},\n\
}}"
);
let mut method_bodies = Vec::new();
for method in &error.methods {
let method_src = match method.name.as_str() {
"status_code" => " /// HTTP status code for this error variant.\n \
#[wasm_bindgen(js_name = \"statusCode\")]\n \
pub fn status_code(&self) -> u16 {\n \
self.inner.status_code()\n }"
.to_string(),
"is_transient" => " /// Returns `true` if the error is transient and a retry may succeed.\n \
#[wasm_bindgen(js_name = \"isTransient\")]\n \
pub fn is_transient(&self) -> bool {\n \
self.inner.is_transient()\n }"
.to_string(),
"error_type" => " /// Returns a machine-readable error category string.\n \
#[wasm_bindgen(js_name = \"errorType\")]\n \
pub fn error_type(&self) -> String {\n \
self.inner.error_type().to_string()\n }"
.to_string(),
other => {
format!(
" // TODO: emit binding for method `{other}` on `{wasm_struct_name}`\n \
#[allow(dead_code)]\n \
pub fn {other}(&self) {{}}"
)
}
};
method_bodies.push(method_src);
}
let impl_block = format!(
"#[wasm_bindgen]\nimpl {wasm_struct_name} {{\n{}\n}}",
method_bodies.join("\n\n")
);
format!("{struct_def}\n\n{impl_block}")
}
pub fn gen_pyo3_error_methods_impl(error: &ErrorDef) -> String {
if error.methods.is_empty() {
return String::new();
}
let struct_name = format!("{}Info", error.name);
let snake_name = to_snake_case(&error.name);
let fn_name = format!("{snake_name}_info");
let mut fields = Vec::new();
let mut getters = Vec::new();
let has_status_code = error.methods.iter().any(|m| m.name == "status_code");
let has_is_transient = error.methods.iter().any(|m| m.name == "is_transient");
let has_error_type = error.methods.iter().any(|m| m.name == "error_type");
if has_status_code {
fields.push(" pub status_code: u16,".to_string());
getters.push(
concat!(
" /// HTTP status code for this error (0 means no associated status).\n",
" #[getter]\n",
" fn status_code(&self) -> u16 {\n",
" self.status_code\n",
" }",
)
.to_string(),
);
}
if has_is_transient {
fields.push(" pub is_transient: bool,".to_string());
getters.push(
concat!(
" /// Returns `true` if the error is transient and a retry may succeed.\n",
" #[getter]\n",
" fn is_transient(&self) -> bool {\n",
" self.is_transient\n",
" }",
)
.to_string(),
);
}
if has_error_type {
fields.push(" pub error_type: String,".to_string());
getters.push(
concat!(
" /// Machine-readable error category string for matching and logging.\n",
" #[getter]\n",
" fn error_type(&self) -> String {\n",
" self.error_type.clone()\n",
" }",
)
.to_string(),
);
}
for method in &error.methods {
match method.name.as_str() {
"status_code" | "is_transient" | "error_type" => {}
other => getters.push(format!(
" // TODO: emit getter for method `{other}` on `{struct_name}`"
)),
}
}
let mut ctor_fields = Vec::new();
if has_status_code {
ctor_fields.push(
" status_code: args\n\
\x20 .as_ref()\n\
\x20 .and_then(|a| a.get_item(1).ok())\n\
\x20 .and_then(|v| v.extract::<u16>().ok())\n\
\x20 .unwrap_or(0),",
);
}
if has_is_transient {
ctor_fields.push(
" is_transient: args\n\
\x20 .as_ref()\n\
\x20 .and_then(|a| a.get_item(2).ok())\n\
\x20 .and_then(|v| v.extract::<bool>().ok())\n\
\x20 .unwrap_or(false),",
);
}
if has_error_type {
ctor_fields.push(
" error_type: args\n\
\x20 .as_ref()\n\
\x20 .and_then(|a| a.get_item(3).ok())\n\
\x20 .and_then(|v| v.extract::<String>().ok())\n\
\x20 .unwrap_or_default(),",
);
}
let struct_def = format!(
"#[pyclass(name = \"{struct_name}\")]\npub struct {struct_name} {{\n{}\n}}",
fields.join("\n")
);
let impl_block = format!("#[pymethods]\nimpl {struct_name} {{\n{}\n}}", getters.join("\n\n"));
let free_fn = format!(
"/// Build a `{struct_name}` from any exception raised by the `{error_name}` hierarchy.\n\
///\n\
/// The converter stores `(message, status_code, is_transient, error_type)` in the\n\
/// exception args tuple; this function extracts those values at indices 1–3.\n\
#[pyfunction]\n\
pub fn {fn_name}(err: pyo3::Bound<'_, pyo3::types::PyAny>) -> {struct_name} {{\n\
let args = err.getattr(\"args\").ok();\n\
{struct_name} {{\n\
{ctor}\n\
}}\n\
}}",
error_name = error.name,
ctor = ctor_fields.join("\n"),
);
format!("{struct_def}\n\n{impl_block}\n\n{free_fn}")
}
pub fn pyo3_error_has_methods(error: &ErrorDef) -> bool {
!error.methods.is_empty()
}
pub fn pyo3_error_info_struct_name(error: &ErrorDef) -> String {
format!("{}Info", error.name)
}
pub fn pyo3_error_info_fn_name(error: &ErrorDef) -> String {
format!("{}_info", to_snake_case(&error.name))
}
pub fn gen_napi_error_class(error: &ErrorDef, core_import: &str) -> String {
if error.methods.is_empty() {
return String::new();
}
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let struct_name = format!("Js{}Info", error.name);
let mut fields = Vec::new();
let mut methods = Vec::new();
let mut ctor_assignments = Vec::new();
for method in &error.methods {
match method.name.as_str() {
"status_code" => {
fields.push(" pub status_code: u16,".to_string());
methods.push(
concat!(
" /// HTTP status code for this error (0 means no associated status).\n",
" #[napi(js_name = \"statusCode\")]\n",
" pub fn status_code(&self) -> u16 {\n",
" self.status_code\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" status_code: e.status_code(),".to_string());
}
"is_transient" => {
fields.push(" pub is_transient: bool,".to_string());
methods.push(
concat!(
" /// Returns `true` if the error is transient and a retry may succeed.\n",
" #[napi(js_name = \"isTransient\")]\n",
" pub fn is_transient(&self) -> bool {\n",
" self.is_transient\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" is_transient: e.is_transient(),".to_string());
}
"error_type" => {
fields.push(" pub error_type: String,".to_string());
methods.push(
concat!(
" /// Machine-readable error category string for matching and logging.\n",
" #[napi(js_name = \"errorType\")]\n",
" pub fn error_type(&self) -> String {\n",
" self.error_type.clone()\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" error_type: e.error_type().to_string(),".to_string());
}
other => {
methods.push(format!(" // TODO: emit #[napi] method `{other}` on `{struct_name}`"));
}
}
}
let struct_def = format!("#[napi]\npub struct {struct_name} {{\n{}\n}}", fields.join("\n"));
let from_fn = format!(
"#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n {struct_name} {{\n{}\n }}\n}}",
ctor_assignments.join("\n"),
snake_name = to_snake_case(&error.name),
);
let impl_block = format!("#[napi]\nimpl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
}
pub fn gen_magnus_error_methods_struct(error: &ErrorDef, core_import: &str) -> String {
if error.methods.is_empty() {
return String::new();
}
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let struct_name = format!("{}Info", error.name);
let mut fields = Vec::new();
let mut methods = Vec::new();
let mut ctor_assignments = Vec::new();
for method in &error.methods {
match method.name.as_str() {
"status_code" => {
fields.push(" status_code: u16,".to_string());
methods.push(
concat!(
" /// HTTP status code for this error (0 means no associated status).\n",
" pub fn status_code(&self) -> u16 {\n",
" self.status_code\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" status_code: e.status_code(),".to_string());
}
"is_transient" => {
fields.push(" is_transient: bool,".to_string());
methods.push(
concat!(
" /// Returns `true` if the error is transient and a retry may succeed.\n",
" pub fn transient(&self) -> bool {\n",
" self.is_transient\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" is_transient: e.is_transient(),".to_string());
}
"error_type" => {
fields.push(" error_type: String,".to_string());
methods.push(
concat!(
" /// Machine-readable error category string for matching and logging.\n",
" pub fn error_type(&self) -> String {\n",
" self.error_type.clone()\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" error_type: e.error_type().to_string(),".to_string());
}
other => {
methods.push(format!(" // TODO: emit method `{other}` on `{struct_name}`"));
}
}
}
let struct_def = format!(
"#[magnus::wrap(class = \"{struct_name}\", free_immediately, size)]\npub struct {struct_name} {{\n{}\n}}",
fields.join("\n")
);
let from_fn = format!(
"#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n {struct_name} {{\n{}\n }}\n}}",
ctor_assignments.join("\n"),
snake_name = to_snake_case(&error.name),
);
let impl_block = format!("impl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
}
pub fn magnus_error_methods_registrations(error: &ErrorDef) -> Vec<String> {
if error.methods.is_empty() {
return Vec::new();
}
let struct_name = format!("{}Info", error.name);
let snake = to_snake_case(&error.name);
let class_var = format!("{snake}_info_class");
let mut lines = Vec::new();
lines.push(format!(
" let {class_var} = module.define_class(\"{struct_name}\", ruby.class_object())?;"
));
for method in &error.methods {
let (ruby_name, rust_fn) = if method.name == "is_transient" {
("transient?".to_string(), "transient".to_string())
} else {
(method.name.clone(), method.name.clone())
};
lines.push(format!(
" {class_var}.define_method(\"{ruby_name}\", magnus::method!({struct_name}::{rust_fn}, 0))?;"
));
}
lines
}
pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));
let mut variants = Vec::new();
for variant in &error.variants {
let pattern = error_variant_wildcard_pattern(&rust_path, variant);
variants.push((pattern, variant.name.clone()));
}
crate::template_env::render(
"error_gen/php_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
variants => variants,
},
)
}
pub fn php_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_php_err", to_snake_case(&error.name))
}
pub fn gen_php_error_methods_impl(error: &ErrorDef, core_import: &str) -> String {
if error.methods.is_empty() {
return String::new();
}
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let struct_name = format!("{}Info", error.name);
let mut fields = Vec::new();
let mut methods = Vec::new();
let mut ctor_assignments = Vec::new();
for method in &error.methods {
match method.name.as_str() {
"status_code" => {
fields.push(" pub status_code: u16,".to_string());
methods.push(
concat!(
" /// HTTP status code for this error (0 means no associated status).\n",
" pub fn status_code(&self) -> u16 {\n",
" self.status_code\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" status_code: e.status_code(),".to_string());
}
"is_transient" => {
fields.push(" pub is_transient: bool,".to_string());
methods.push(
concat!(
" /// Returns `true` if the error is transient and a retry may succeed.\n",
" pub fn is_transient(&self) -> bool {\n",
" self.is_transient\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" is_transient: e.is_transient(),".to_string());
}
"error_type" => {
fields.push(" pub error_type: String,".to_string());
methods.push(
concat!(
" /// Machine-readable error category string for matching and logging.\n",
" pub fn error_type(&self) -> String {\n",
" self.error_type.clone()\n",
" }",
)
.to_string(),
);
ctor_assignments.push(" error_type: e.error_type().to_string(),".to_string());
}
other => {
methods.push(format!(" // TODO: emit method for `{other}` on `{struct_name}`"));
}
}
}
let struct_def = format!("#[php_class]\npub struct {struct_name} {{\n{}\n}}", fields.join("\n"));
let from_fn = format!(
"#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n {struct_name} {{\n{}\n }}\n}}",
ctor_assignments.join("\n"),
snake_name = to_snake_case(&error.name),
);
let impl_block = format!("#[php_impl]\nimpl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
}
pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));
crate::template_env::render(
"error_gen/magnus_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
},
)
}
pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_magnus_err", to_snake_case(&error.name))
}
pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));
crate::template_env::render(
"error_gen/rustler_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
},
)
}
pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_rustler_err", to_snake_case(&error.name))
}
pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
let prefix = to_screaming_snake(&error.name);
let prefix_lower = to_snake_case(&error.name);
let mut variant_variants = Vec::new();
for (i, variant) in error.variants.iter().enumerate() {
let variant_screaming = to_screaming_snake(&variant.name);
variant_variants.push((variant_screaming, (i + 1).to_string()));
}
crate::template_env::render(
"error_gen/ffi_error_codes.jinja",
minijinja::context! {
error_name => error.name.as_str(),
prefix => prefix.as_str(),
prefix_lower => prefix_lower.as_str(),
variant_variants => variant_variants,
},
)
}
pub fn gen_ffi_error_methods(error: &ErrorDef, core_import: &str, api_prefix: &str) -> String {
if error.methods.is_empty() {
return String::new();
}
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let error_snake = to_snake_case(&error.name);
let mut items: Vec<String> = Vec::new();
for method in &error.methods {
match method.name.as_str() {
"status_code" => {
let fn_name = format!("{api_prefix}_{error_snake}_status_code");
items.push(format!(
"/// Return the HTTP status code for the error pointed to by `err`.\n\
/// Returns `0` if `err` is null.\n\
#[no_mangle]\n\
pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> u16 {{\n\
// SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
// allocated by this library, or is null.\n\
if err.is_null() {{\n\
return 0;\n\
}}\n\
(*err).status_code()\n\
}}"
));
}
"is_transient" => {
let fn_name = format!("{api_prefix}_{error_snake}_is_transient");
items.push(format!(
"/// Return whether the error pointed to by `err` is transient.\n\
/// Returns `false` if `err` is null.\n\
#[no_mangle]\n\
pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> bool {{\n\
// SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
// allocated by this library, or is null.\n\
if err.is_null() {{\n\
return false;\n\
}}\n\
(*err).is_transient()\n\
}}"
));
}
"error_type" => {
let fn_name = format!("{api_prefix}_{error_snake}_error_type");
let free_fn_name = format!("{fn_name}_free");
items.push(format!(
"/// Return the machine-readable error category string for the error pointed\n\
/// to by `err` as a heap-allocated, NUL-terminated C string.\n\
/// The caller must free the returned pointer with `{free_fn_name}`.\n\
/// Returns a null pointer if `err` is null.\n\
#[no_mangle]\n\
pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> *mut std::ffi::c_char {{\n\
// SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
// allocated by this library, or is null.\n\
if err.is_null() {{\n\
return std::ptr::null_mut();\n\
}}\n\
let s = (*err).error_type();\n\
// SAFETY: `error_type()` returns a `'static str` containing no NUL bytes.\n\
std::ffi::CString::new(s)\n\
.map(|c| c.into_raw())\n\
.unwrap_or(std::ptr::null_mut())\n\
}}\n\n\
/// Free a string previously returned by `{fn_name}`.\n\
/// Passing a null pointer is a no-op.\n\
#[no_mangle]\n\
pub unsafe extern \"C\" fn {free_fn_name}(ptr: *mut std::ffi::c_char) {{\n\
// SAFETY: `ptr` was allocated by `CString::into_raw` inside\n\
// `{fn_name}` and is now being reclaimed by the matching\n\
// `CString::from_raw`. Passing null is explicitly allowed.\n\
if !ptr.is_null() {{\n\
drop(std::ffi::CString::from_raw(ptr));\n\
}}\n\
}}"
));
}
other => {
items.push(format!(
"// TODO: emit FFI helper for method `{other}` on `{rust_path}`"
));
}
}
}
items.join("\n\n")
}
pub fn gen_go_error_types(error: &ErrorDef, pkg_name: &str) -> String {
let sentinels = gen_go_sentinel_errors(std::slice::from_ref(error));
let structured = gen_go_error_struct(error, pkg_name);
format!("{}\n\n{}", sentinels, structured)
}
pub fn gen_go_sentinel_errors(errors: &[ErrorDef]) -> String {
if errors.is_empty() {
return String::new();
}
let mut variant_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for err in errors {
for v in &err.variants {
*variant_counts.entry(v.name.as_str()).or_insert(0) += 1;
}
}
let mut seen = std::collections::HashSet::new();
let mut sentinels = Vec::new();
for err in errors {
let parent_base = error_base_prefix(&err.name);
for variant in &err.variants {
let collides = variant_counts.get(variant.name.as_str()).copied().unwrap_or(0) > 1;
let const_name = if collides {
format!("Err{}{}", parent_base, variant.name)
} else {
format!("Err{}", variant.name)
};
if !seen.insert(const_name.clone()) {
continue;
}
let msg = variant_display_message(variant);
sentinels.push((const_name, msg));
}
}
crate::template_env::render(
"error_gen/go_sentinel_errors.jinja",
minijinja::context! {
sentinels => sentinels,
},
)
}
pub fn gen_go_error_struct(error: &ErrorDef, pkg_name: &str) -> String {
let go_type_name = strip_package_prefix(&error.name, pkg_name);
let methods: Vec<serde_json::Value> = error
.methods
.iter()
.map(|m| {
let go_type = typeref_to_go_type(&m.return_type);
let method_name = to_pascal_case(&m.name);
serde_json::json!({
"field_name": method_name,
"go_type": go_type,
"method_name": method_name,
"doc": m.doc,
})
})
.collect();
let has_methods = !methods.is_empty();
crate::template_env::render(
"error_gen/go_error_struct.jinja",
minijinja::context! {
go_type_name => go_type_name.as_str(),
methods => methods,
has_methods => has_methods,
},
)
}
fn typeref_to_go_type(ty: &alef_core::ir::TypeRef) -> &'static str {
use alef_core::ir::{PrimitiveType, TypeRef};
match ty {
TypeRef::Primitive(PrimitiveType::Bool) => "bool",
TypeRef::Primitive(PrimitiveType::U8) => "uint8",
TypeRef::Primitive(PrimitiveType::U16) => "uint16",
TypeRef::Primitive(PrimitiveType::U32) => "uint32",
TypeRef::Primitive(PrimitiveType::U64) => "uint64",
TypeRef::Primitive(PrimitiveType::I8) => "int8",
TypeRef::Primitive(PrimitiveType::I16) => "int16",
TypeRef::Primitive(PrimitiveType::I32) => "int32",
TypeRef::Primitive(PrimitiveType::I64) => "int64",
TypeRef::Primitive(PrimitiveType::F32) => "float32",
TypeRef::Primitive(PrimitiveType::F64) => "float64",
TypeRef::String => "string",
_ => "string",
}
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().to_string() + chars.as_str(),
}
})
.collect()
}
fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
let type_lower = type_name.to_lowercase();
let pkg_lower = pkg_name.to_lowercase();
if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
type_name[pkg_lower.len()..].to_string()
} else {
type_name.to_string()
}
}
pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
let mut files = Vec::with_capacity(error.variants.len() + 1);
let base_name = format!("{}Exception", error.name);
let doc_lines: Vec<&str> = error.doc.lines().collect();
let method_infos: Vec<serde_json::Value> = error
.methods
.iter()
.map(|m| {
let java_type = typeref_to_java_type(&m.return_type);
let getter_name = java_getter_name(&m.name);
let field_name = java_field_name(&m.name);
let default_value = java_default_value(&m.return_type);
serde_json::json!({
"field_name": field_name,
"java_type": java_type,
"getter_name": getter_name,
"default_value": default_value,
"doc": m.doc,
})
})
.collect();
let has_methods = !method_infos.is_empty();
let base = crate::template_env::render(
"error_gen/java_error_base.jinja",
minijinja::context! {
package => package,
base_name => base_name.as_str(),
doc => !error.doc.is_empty(),
doc_lines => doc_lines,
methods => method_infos,
has_methods => has_methods,
},
);
files.push((base_name.clone(), base));
for variant in &error.variants {
let class_name = format!("{}Exception", variant.name);
let doc_lines: Vec<&str> = variant.doc.lines().collect();
let content = crate::template_env::render(
"error_gen/java_error_variant.jinja",
minijinja::context! {
package => package,
class_name => class_name.as_str(),
base_name => base_name.as_str(),
doc => !variant.doc.is_empty(),
doc_lines => doc_lines,
has_methods => has_methods,
},
);
files.push((class_name, content));
}
files
}
fn typeref_to_java_type(ty: &alef_core::ir::TypeRef) -> &'static str {
use alef_core::ir::{PrimitiveType, TypeRef};
match ty {
TypeRef::Primitive(PrimitiveType::Bool) => "boolean",
TypeRef::Primitive(
PrimitiveType::U8
| PrimitiveType::I8
| PrimitiveType::I16
| PrimitiveType::U16
| PrimitiveType::I32
| PrimitiveType::U32,
) => "int",
TypeRef::Primitive(PrimitiveType::I64 | PrimitiveType::U64) => "long",
TypeRef::Primitive(PrimitiveType::F32) => "float",
TypeRef::Primitive(PrimitiveType::F64) => "double",
TypeRef::String => "String",
_ => "String",
}
}
fn java_getter_name(snake: &str) -> String {
if let Some(rest) = snake.strip_prefix("is_") {
let pascal = to_pascal_case(rest);
format!("is{pascal}")
} else {
let pascal = to_pascal_case(snake);
format!("get{pascal}")
}
}
fn java_field_name(snake: &str) -> String {
let parts: Vec<&str> = snake.split('_').collect();
if parts.is_empty() {
return snake.to_string();
}
let mut out = parts[0].to_string();
for part in &parts[1..] {
let mut chars = part.chars();
match chars.next() {
None => {}
Some(first) => {
out.push_str(&first.to_uppercase().to_string());
out.push_str(chars.as_str());
}
}
}
out
}
fn java_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
use alef_core::ir::{PrimitiveType, TypeRef};
match ty {
TypeRef::Primitive(PrimitiveType::Bool) => "false",
TypeRef::String => "\"\"",
_ => "0",
}
}
pub fn gen_csharp_error_types(
error: &ErrorDef,
namespace: &str,
fallback_class: Option<&str>,
) -> Vec<(String, String)> {
let mut files = Vec::with_capacity(error.variants.len() + 1);
let base_name = format!("{}Exception", error.name);
let base_parent = fallback_class.unwrap_or("Exception");
let error_doc_lines: Vec<&str> = error.doc.lines().collect();
let method_infos: Vec<serde_json::Value> = error
.methods
.iter()
.map(|m| {
let cs_type = typeref_to_csharp_type(&m.return_type);
let prop_name = to_pascal_case(&m.name);
let param_name = java_field_name(&m.name); let default_value = csharp_default_value(&m.return_type);
serde_json::json!({
"prop_name": prop_name,
"cs_type": cs_type,
"param_name": param_name,
"default_value": default_value,
"doc": m.doc,
})
})
.collect();
let has_methods = !method_infos.is_empty();
{
let out = crate::template_env::render(
"error_gen/csharp_error_base.jinja",
minijinja::context! {
namespace => namespace,
base_name => base_name.as_str(),
base_parent => base_parent,
doc => !error.doc.is_empty(),
doc_lines => error_doc_lines,
methods => method_infos,
has_methods => has_methods,
},
);
files.push((base_name.clone(), out));
}
for variant in &error.variants {
let class_name = format!("{}Exception", variant.name);
let variant_doc_lines: Vec<&str> = variant.doc.lines().collect();
let out = crate::template_env::render(
"error_gen/csharp_error_variant.jinja",
minijinja::context! {
namespace => namespace,
class_name => class_name.as_str(),
base_name => base_name.as_str(),
doc => !variant.doc.is_empty(),
doc_lines => variant_doc_lines,
has_methods => has_methods,
},
);
files.push((class_name, out));
}
files
}
fn typeref_to_csharp_type(ty: &alef_core::ir::TypeRef) -> &'static str {
use alef_core::ir::{PrimitiveType, TypeRef};
match ty {
TypeRef::Primitive(PrimitiveType::Bool) => "bool",
TypeRef::Primitive(PrimitiveType::U8) => "byte",
TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
TypeRef::Primitive(PrimitiveType::I16) => "short",
TypeRef::Primitive(PrimitiveType::U16) => "ushort",
TypeRef::Primitive(PrimitiveType::I32) => "int",
TypeRef::Primitive(PrimitiveType::U32) => "uint",
TypeRef::Primitive(PrimitiveType::I64) => "long",
TypeRef::Primitive(PrimitiveType::U64) => "ulong",
TypeRef::Primitive(PrimitiveType::F32) => "float",
TypeRef::Primitive(PrimitiveType::F64) => "double",
TypeRef::String => "string",
_ => "string",
}
}
fn csharp_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
use alef_core::ir::{PrimitiveType, TypeRef};
match ty {
TypeRef::Primitive(PrimitiveType::Bool) => "false",
TypeRef::String => "string.Empty",
_ => "0",
}
}
fn to_screaming_snake(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(c.to_ascii_uppercase());
} else {
result.push(c.to_ascii_uppercase());
}
}
result
}
const TECHNICAL_ACRONYMS: &[&str] = &[
"API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
"ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
"RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
"URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
];
pub fn strip_thiserror_placeholders(template: &str) -> String {
let mut without_placeholders = String::with_capacity(template.len());
let mut depth = 0u32;
for ch in template.chars() {
match ch {
'{' => depth = depth.saturating_add(1),
'}' => depth = depth.saturating_sub(1),
other if depth == 0 => without_placeholders.push(other),
_ => {}
}
}
let mut compacted = String::with_capacity(without_placeholders.len());
let mut last_was_space = false;
for ch in without_placeholders.chars() {
if ch.is_whitespace() {
if !last_was_space && !compacted.is_empty() {
compacted.push(' ');
}
last_was_space = true;
} else {
compacted.push(ch);
last_was_space = false;
}
}
let trimmed = compacted
.trim()
.trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
.trim();
let cleaned = trimmed
.replace("()", "")
.replace("''", "")
.replace("\"\"", "")
.replace(" ", " ");
cleaned.trim().to_string()
}
pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
if variant_name.is_empty() {
return String::new();
}
let bytes = variant_name.as_bytes();
let mut words: Vec<&str> = Vec::new();
let mut start = 0usize;
for i in 1..bytes.len() {
if bytes[i].is_ascii_uppercase() {
words.push(&variant_name[start..i]);
start = i;
}
}
words.push(&variant_name[start..]);
let mut rendered: Vec<String> = Vec::with_capacity(words.len());
for word in &words {
let upper = word.to_ascii_uppercase();
if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
rendered.push(upper);
} else {
rendered.push(word.to_ascii_lowercase());
}
}
rendered.join(" ")
}
fn variant_display_message(variant: &ErrorVariant) -> String {
if let Some(tmpl) = &variant.message_template {
let stripped = strip_thiserror_placeholders(tmpl);
if stripped.is_empty() {
return acronym_aware_snake_phrase(&variant.name);
}
let mut tokens = stripped.splitn(2, ' ');
let head = tokens.next().unwrap_or("").to_string();
let tail = tokens.next().unwrap_or("");
let head_upper = head.to_ascii_uppercase();
let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
head_upper
} else {
let mut chars = head.chars();
match chars.next() {
Some(c) => c.to_lowercase().to_string() + chars.as_str(),
None => head,
}
};
if tail.is_empty() {
head_rendered
} else {
format!("{} {}", head_rendered, tail)
}
} else {
acronym_aware_snake_phrase(&variant.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use alef_core::ir::{ErrorDef, ErrorVariant};
use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
fn tuple_field(index: usize) -> FieldDef {
FieldDef {
name: format!("_{index}"),
ty: TypeRef::String,
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: CoreWrapper::None,
vec_inner_core_wrapper: CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}
}
fn named_field(name: &str) -> FieldDef {
FieldDef {
name: name.to_string(),
ty: TypeRef::String,
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: CoreWrapper::None,
vec_inner_core_wrapper: CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}
}
fn sample_error() -> ErrorDef {
ErrorDef {
name: "ConversionError".to_string(),
rust_path: "html_to_markdown_rs::ConversionError".to_string(),
original_rust_path: String::new(),
variants: vec![
ErrorVariant {
name: "ParseError".to_string(),
message_template: Some("HTML parsing error: {0}".to_string()),
fields: vec![tuple_field(0)],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
ErrorVariant {
name: "IoError".to_string(),
message_template: Some("I/O error: {0}".to_string()),
fields: vec![tuple_field(0)],
has_source: false,
has_from: true,
is_unit: false,
doc: String::new(),
},
ErrorVariant {
name: "Other".to_string(),
message_template: Some("Conversion error: {0}".to_string()),
fields: vec![tuple_field(0)],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
],
doc: "Error type for conversion operations.".to_string(),
methods: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
}
}
#[test]
fn test_gen_error_types() {
let error = sample_error();
let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
}
#[test]
fn test_gen_error_converter() {
let error = sample_error();
let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
assert!(
output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
);
assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
}
#[test]
fn test_gen_error_registration() {
let error = sample_error();
let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
assert_eq!(regs.len(), 4); assert!(regs[0].contains("\"ParseError\""));
assert!(regs[3].contains("\"ConversionError\""));
}
#[test]
fn test_unit_variant_pattern() {
let error = ErrorDef {
name: "MyError".to_string(),
rust_path: "my_crate::MyError".to_string(),
original_rust_path: String::new(),
variants: vec![ErrorVariant {
name: "NotFound".to_string(),
message_template: Some("not found".to_string()),
fields: vec![],
has_source: false,
has_from: false,
is_unit: true,
doc: String::new(),
}],
doc: String::new(),
methods: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
};
let output = gen_pyo3_error_converter(&error, "my_crate");
assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
assert!(!output.contains("NotFound(..)"));
}
#[test]
fn test_struct_variant_pattern() {
let error = ErrorDef {
name: "MyError".to_string(),
rust_path: "my_crate::MyError".to_string(),
original_rust_path: String::new(),
variants: vec![ErrorVariant {
name: "Parsing".to_string(),
message_template: Some("parsing error: {message}".to_string()),
fields: vec![named_field("message")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
}],
doc: String::new(),
methods: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
};
let output = gen_pyo3_error_converter(&error, "my_crate");
assert!(
output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
"Struct variants must use {{ .. }} pattern, got:\n{output}"
);
assert!(!output.contains("Parsing(..)"));
}
#[test]
fn test_gen_napi_error_types() {
let error = sample_error();
let output = gen_napi_error_types(&error);
assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
}
#[test]
fn test_gen_napi_error_converter() {
let error = sample_error();
let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
assert!(
output
.contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
);
assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
assert!(output.contains("[ParseError]"));
assert!(output.contains("[IoError]"));
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_napi_unit_variant() {
let error = ErrorDef {
name: "MyError".to_string(),
rust_path: "my_crate::MyError".to_string(),
original_rust_path: String::new(),
variants: vec![ErrorVariant {
name: "NotFound".to_string(),
message_template: None,
fields: vec![],
has_source: false,
has_from: false,
is_unit: true,
doc: String::new(),
}],
doc: String::new(),
methods: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
};
let output = gen_napi_error_converter(&error, "my_crate");
assert!(output.contains("my_crate::MyError::NotFound =>"));
assert!(!output.contains("NotFound(..)"));
}
#[test]
fn test_gen_wasm_error_converter() {
let error = sample_error();
let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
assert!(output.contains(
"fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
));
assert!(output.contains("js_sys::Object::new()"));
assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
assert!(output.contains("obj.into()"));
assert!(
output
.contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
);
assert!(output.contains("\"parse_error\""));
assert!(output.contains("\"io_error\""));
assert!(output.contains("\"other\""));
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_gen_php_error_converter() {
let error = sample_error();
let output = gen_php_error_converter(&error, "html_to_markdown_rs");
assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_gen_magnus_error_converter() {
let error = sample_error();
let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
assert!(
output.contains(
"fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
)
);
assert!(
output.contains(
"magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
)
);
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_gen_rustler_error_converter() {
let error = sample_error();
let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
assert!(
output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
);
assert!(output.contains("e.to_string()"));
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_gen_go_error_struct_with_methods() {
let error = error_with_methods();
let output = gen_go_error_struct(&error, "literllm");
assert!(output.contains("type Error struct {"), "struct def: {output}");
assert!(output.contains("StatusCode uint16"), "StatusCode field: {output}");
assert!(output.contains("IsTransient bool"), "IsTransient field: {output}");
assert!(output.contains("ErrorType string"), "ErrorType field: {output}");
assert!(
output.contains("func (e Error) StatusCode() uint16 { return e.StatusCode }"),
"{output}"
);
assert!(
output.contains("func (e Error) IsTransient() bool { return e.IsTransient }"),
"{output}"
);
assert!(
output.contains("func (e Error) ErrorType() string { return e.ErrorType }"),
"{output}"
);
}
#[test]
fn test_gen_go_error_struct_no_methods() {
let error = sample_error(); let output = gen_go_error_struct(&error, "mylib");
assert!(output.contains("type ConversionError struct {"), "{output}");
assert!(!output.contains("StatusCode"), "{output}");
assert!(!output.contains("IsTransient"), "{output}");
}
#[test]
fn test_gen_java_error_types_with_methods() {
let error = error_with_methods();
let files = gen_java_error_types(&error, "dev.kreuzberg.literllm");
assert_eq!(files.len(), 1); let base = &files[0].1;
assert!(
base.contains("private final int statusCode;"),
"statusCode field: {base}"
);
assert!(
base.contains("private final boolean isTransient;"),
"isTransient field: {base}"
);
assert!(
base.contains("private final String errorType;"),
"errorType field: {base}"
);
assert!(
base.contains("public int getStatusCode()"),
"getStatusCode getter: {base}"
);
assert!(
base.contains("public boolean isTransient()"),
"isTransient getter: {base}"
);
assert!(
base.contains("public String getErrorType()"),
"getErrorType getter: {base}"
);
assert!(
base.contains("public LiterLlmErrorException(final String message)"),
"simple ctor: {base}"
);
assert!(
base.contains("public LiterLlmErrorException(final String message, final int statusCode, final boolean isTransient, final String errorType)"),
"full ctor: {base}"
);
}
#[test]
fn test_gen_java_error_types_no_methods() {
let error = sample_error(); let files = gen_java_error_types(&error, "dev.kreuzberg.test");
let base = &files[0].1;
assert!(!base.contains("private final"), "no fields when no methods: {base}");
assert!(
base.contains("public ConversionErrorException(final String message)"),
"{base}"
);
}
#[test]
fn test_gen_csharp_error_types_with_methods() {
let error = error_with_methods();
let files = gen_csharp_error_types(&error, "Kreuzberg.LiterLlm", None);
assert_eq!(files.len(), 1); let base = &files[0].1;
assert!(
base.contains("public ushort StatusCode { get; }"),
"StatusCode prop: {base}"
);
assert!(
base.contains("public bool IsTransient { get; }"),
"IsTransient prop: {base}"
);
assert!(
base.contains("public string ErrorType { get; }"),
"ErrorType prop: {base}"
);
assert!(
base.contains("public LiterLlmErrorException(string message) : base(message)"),
"simple ctor: {base}"
);
assert!(
base.contains("public LiterLlmErrorException(string message, ushort statusCode, bool isTransient, string errorType) : base(message)"),
"full ctor: {base}"
);
}
#[test]
fn test_gen_csharp_error_types_no_methods() {
let error = sample_error(); let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
let base = &files[0].1;
assert!(!base.contains("{ get; }"), "no properties when no methods: {base}");
assert!(
base.contains("public ConversionErrorException(string message) : base(message) { }"),
"{base}"
);
}
#[test]
fn test_to_screaming_snake() {
assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
assert_eq!(to_screaming_snake("Other"), "OTHER");
}
#[test]
fn test_strip_thiserror_placeholders_struct_field() {
assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
assert_eq!(
strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
"plugin error in"
);
let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
assert!(!result.contains('{'), "no braces: {result}");
assert!(!result.contains('}'), "no braces: {result}");
assert!(result.starts_with("extraction timed out after"), "{result}");
}
#[test]
fn test_strip_thiserror_placeholders_positional() {
assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
}
#[test]
fn test_strip_thiserror_placeholders_no_placeholder() {
assert_eq!(strip_thiserror_placeholders("not found"), "not found");
assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
}
#[test]
fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
}
#[test]
fn test_acronym_aware_snake_phrase_plain_words() {
assert_eq!(acronym_aware_snake_phrase("Other"), "other");
assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
}
#[test]
fn test_variant_display_message_acronym_first_word() {
let variant = ErrorVariant {
name: "Io".to_string(),
message_template: Some("I/O error: {0}".to_string()),
fields: vec![tuple_field(0)],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
};
let msg = variant_display_message(&variant);
assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
}
#[test]
fn test_variant_display_message_no_template_uses_acronyms() {
let variant = ErrorVariant {
name: "IoError".to_string(),
message_template: None,
fields: vec![],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
};
assert_eq!(variant_display_message(&variant), "IO error");
}
#[test]
fn test_variant_display_message_struct_template_no_leak() {
let variant = ErrorVariant {
name: "Ocr".to_string(),
message_template: Some("OCR error: {message}".to_string()),
fields: vec![named_field("message")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
};
let msg = variant_display_message(&variant);
assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
}
#[test]
fn test_go_sentinels_no_placeholder_leak() {
let error = ErrorDef {
name: "KreuzbergError".to_string(),
rust_path: "kreuzberg::KreuzbergError".to_string(),
original_rust_path: String::new(),
variants: vec![
ErrorVariant {
name: "Io".to_string(),
message_template: Some("IO error: {message}".to_string()),
fields: vec![named_field("message")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
ErrorVariant {
name: "Ocr".to_string(),
message_template: Some("OCR error: {message}".to_string()),
fields: vec![named_field("message")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
ErrorVariant {
name: "Timeout".to_string(),
message_template: Some(
"extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
),
fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
],
doc: String::new(),
methods: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
};
let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
assert!(
!output.contains('{'),
"Go sentinels must not contain raw placeholders:\n{output}"
);
assert!(
output.contains("ErrIo = errors.New(\"IO error\")"),
"expected acronym-preserving Io sentinel, got:\n{output}"
);
assert!(
output.contains("var (\n\t// ErrIo is returned when IO error.\n\tErrIo = errors.New(\"IO error\")\n"),
"Go sentinel comments must be emitted on separate lines, got:\n{output}"
);
assert!(
output.contains("ErrOcr = errors.New(\"OCR error\")"),
"expected acronym-preserving Ocr sentinel, got:\n{output}"
);
assert!(
output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
"expected timeout sentinel to start with the prose, got:\n{output}"
);
}
#[test]
fn test_gen_ffi_error_codes() {
let error = sample_error();
let output = gen_ffi_error_codes(&error);
assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
assert!(output.contains("conversion_error_t;"));
assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
}
#[test]
fn test_gen_go_error_types() {
let error = sample_error();
let output = gen_go_error_types(&error, "mylib");
assert!(output.contains("ErrParseError = errors.New("));
assert!(output.contains("ErrIoError = errors.New("));
assert!(output.contains("ErrOther = errors.New("));
assert!(output.contains("type ConversionError struct {"));
assert!(output.contains("Code string"));
assert!(output.contains("func (e ConversionError) Error() string"));
assert!(output.contains("// ErrParseError is returned when"));
assert!(output.contains("// ErrIoError is returned when"));
assert!(output.contains("// ErrOther is returned when"));
}
#[test]
fn test_gen_go_error_types_stutter_strip() {
let error = sample_error();
let output = gen_go_error_types(&error, "conversion");
assert!(
output.contains("type Error struct {"),
"expected stutter strip, got:\n{output}"
);
assert!(
output.contains("func (e Error) Error() string"),
"expected stutter strip, got:\n{output}"
);
assert!(output.contains("ErrParseError = errors.New("));
}
#[test]
fn test_gen_java_error_types() {
let error = sample_error();
let files = gen_java_error_types(&error, "dev.kreuzberg.test");
assert_eq!(files.len(), 4);
assert_eq!(files[0].0, "ConversionErrorException");
assert!(
files[0]
.1
.contains("public class ConversionErrorException extends Exception")
);
assert!(files[0].1.contains("package dev.kreuzberg.test;"));
assert_eq!(files[1].0, "ParseErrorException");
assert!(
files[1]
.1
.contains("public class ParseErrorException extends ConversionErrorException")
);
assert_eq!(files[2].0, "IoErrorException");
assert_eq!(files[3].0, "OtherException");
}
#[test]
fn test_gen_csharp_error_types() {
let error = sample_error();
let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
assert_eq!(files.len(), 4);
assert_eq!(files[0].0, "ConversionErrorException");
assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
assert_eq!(files[1].0, "ParseErrorException");
assert!(
files[1]
.1
.contains("public class ParseErrorException : ConversionErrorException")
);
assert_eq!(files[2].0, "IoErrorException");
assert_eq!(files[3].0, "OtherException");
}
#[test]
fn test_gen_csharp_error_types_with_fallback() {
let error = sample_error();
let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
assert_eq!(files.len(), 4);
assert!(
files[0]
.1
.contains("public class ConversionErrorException : TestLibException")
);
assert!(
files[1]
.1
.contains("public class ParseErrorException : ConversionErrorException")
);
}
#[test]
fn test_python_exception_name_no_conflict() {
assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
}
#[test]
fn test_python_exception_name_shadows_builtin() {
assert_eq!(
python_exception_name("Connection", "CrawlError"),
"CrawlConnectionError"
);
assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
assert_eq!(
python_exception_name("ConnectionError", "CrawlError"),
"CrawlConnectionError"
);
}
#[test]
fn test_python_exception_name_no_double_prefix() {
assert_eq!(
python_exception_name("CrawlConnectionError", "CrawlError"),
"CrawlConnectionError"
);
}
fn sample_method(name: &str, return_type: TypeRef) -> alef_core::ir::MethodDef {
alef_core::ir::MethodDef {
name: name.to_string(),
params: vec![],
return_type,
is_async: false,
is_static: false,
error_type: None,
doc: String::new(),
receiver: Some(alef_core::ir::ReceiverKind::Ref),
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
}
}
fn error_with_methods() -> ErrorDef {
ErrorDef {
name: "LiterLlmError".to_string(),
rust_path: "liter_llm::error::LiterLlmError".to_string(),
original_rust_path: String::new(),
variants: vec![],
doc: String::new(),
methods: vec![
sample_method("status_code", TypeRef::Primitive(alef_core::ir::PrimitiveType::U16)),
sample_method("is_transient", TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool)),
sample_method("error_type", TypeRef::String),
],
binding_excluded: false,
binding_exclusion_reason: None,
}
}
#[test]
fn test_gen_wasm_error_methods_empty_when_no_methods() {
let error = sample_error(); let output = gen_wasm_error_methods(&error, "html_to_markdown_rs", "");
assert!(output.is_empty(), "should produce no output when methods is empty");
}
#[test]
fn test_gen_wasm_error_methods_struct_and_impl() {
let error = error_with_methods();
let output = gen_wasm_error_methods(&error, "liter_llm", "Wasm");
assert!(
output.contains("pub struct WasmLiterLlmError"),
"must emit opaque struct: {output}"
);
assert!(
output.contains("pub(crate) inner: liter_llm::error::LiterLlmError"),
"{output}"
);
assert!(output.contains("#[wasm_bindgen]\nimpl WasmLiterLlmError"), "{output}");
assert!(output.contains("js_name = \"statusCode\""), "{output}");
assert!(output.contains("pub fn status_code(&self) -> u16"), "{output}");
assert!(output.contains("self.inner.status_code()"), "{output}");
assert!(output.contains("js_name = \"isTransient\""), "{output}");
assert!(output.contains("pub fn is_transient(&self) -> bool"), "{output}");
assert!(output.contains("self.inner.is_transient()"), "{output}");
assert!(output.contains("js_name = \"errorType\""), "{output}");
assert!(output.contains("pub fn error_type(&self) -> String"), "{output}");
assert!(output.contains("self.inner.error_type().to_string()"), "{output}");
}
#[test]
fn test_gen_ffi_error_methods_empty_when_no_methods() {
let error = sample_error(); let output = gen_ffi_error_methods(&error, "html_to_markdown_rs", "h2m");
assert!(output.is_empty(), "should produce no output when methods is empty");
}
#[test]
fn test_gen_ffi_error_methods_status_code() {
let error = error_with_methods();
let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
assert!(
output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_status_code("),
"must emit status_code fn: {output}"
);
assert!(
output.contains("err: *const liter_llm::error::LiterLlmError"),
"{output}"
);
assert!(output.contains("-> u16"), "{output}");
assert!(output.contains("(*err).status_code()"), "{output}");
assert!(output.contains("if err.is_null()"), "{output}");
assert!(output.contains("return 0;"), "{output}");
}
#[test]
fn test_gen_ffi_error_methods_is_transient() {
let error = error_with_methods();
let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
assert!(
output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_is_transient("),
"must emit is_transient fn: {output}"
);
assert!(output.contains("-> bool"), "{output}");
assert!(output.contains("(*err).is_transient()"), "{output}");
assert!(output.contains("return false;"), "{output}");
}
#[test]
fn test_gen_ffi_error_methods_error_type_with_free() {
let error = error_with_methods();
let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
assert!(
output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type("),
"must emit error_type fn: {output}"
);
assert!(output.contains("-> *mut std::ffi::c_char"), "{output}");
assert!(output.contains("(*err).error_type()"), "{output}");
assert!(output.contains("CString::new(s)"), "{output}");
assert!(output.contains(".into_raw()"), "{output}");
assert!(output.contains("return std::ptr::null_mut();"), "{output}");
assert!(
output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type_free("),
"must emit _free companion: {output}"
);
assert!(output.contains("drop(std::ffi::CString::from_raw(ptr))"), "{output}");
}
#[test]
fn test_gen_ffi_error_methods_safety_comments() {
let error = error_with_methods();
let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
assert!(output.contains("// SAFETY:"), "must include SAFETY comments: {output}");
}
}