alef 0.109.6

Opinionated polyglot binding generator for Rust libraries
Documentation
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::codegen::template_env::render(
        "error_gen/pyo3_error_types.jinja",
        crate::alef_context! {
            variant_names => variant_names,
            module_name => module_name,
            error_name => error.name.as_str(),
            include_base => include_base,
        },
    )
}

/// Generate a `to_py_err` converter function that maps each Rust error variant to a Python exception.
/// Uses Error-suffixed names for variant exceptions (N818 compliance).
///
/// When `error.methods` is non-empty, the constructor receives a tuple
/// `(message, code, status_code, is_transient, error_type)` so that the `#[getter]`
/// impl generated by [`gen_pyo3_error_methods_impl`] can read them back. The message stays
/// the message alone — the stable numeric `code` travels as its own tuple element, never
/// interpolated into the human-facing text. (~keep)
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();

    // Branch on `has_methods` here, in Rust, rather than inside the template's `{% for %}`
    // loop: a `{%- if %}...{%- else %}...{%- endif %}` nested inside a `{%- for %}` collapses
    // the newline between successive match arms (the leading `-` on each inner tag eats the
    // newline emitted by the previous iteration), producing a single-line `match` body. A flat
    // loop over pre-rendered arm strings has no such interaction. (~keep)
    let slots = info_slots(error);
    let ctor_args = |code: u32| -> String {
        if has_methods {
            let extra: String = slots.iter().map(|slot| format!(", {}", slot.tuple_expr)).collect();
            format!("(msg, {code}u32{extra})")
        } else {
            "msg".to_string()
        }
    };

    let mut arms = Vec::with_capacity(error.variants.len());
    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);
        let code = variant
            .taxonomy(&error.rust_path)
            .map_or(crate::core::ir::ApiSurface::FFI_ERROR_CODE_UNKNOWN, |taxonomy| {
                taxonomy.code
            });
        arms.push(format!(
            "{pattern} => {variant_exc_name}::new_err({}),",
            ctor_args(code)
        ));
    }
    let default_arm = format!(
        "_ => {}::new_err({}),",
        error.name,
        ctor_args(crate::core::ir::ApiSurface::FFI_ERROR_CODE_UNKNOWN)
    );

    crate::codegen::template_env::render(
        "error_gen/pyo3_error_converter.jinja",
        crate::alef_context! {
            rust_path => rust_path.as_str(),
            fn_name => fn_name.as_str(),
            arms => arms,
            default_arm => default_arm.as_str(),
        },
    )
}

/// Generate `m.add(...)` registration calls for each exception type.
/// Uses Error-suffixed names for variant exceptions (N818 compliance).
/// Prefixes names that would shadow Python builtins (A004 compliance).
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
}

/// Return the converter function name for a given error type.
pub fn converter_fn_name(error: &ErrorDef) -> String {
    format!("{}_to_py_err", to_snake_case(&error.name))
}

/// One introspection value the exception args tuple carries after `(message, code)`, and the
/// `{Error}Info` field and getter that read it back. The slot's position in
/// [`info_slots`] is its tuple index minus 2, so the converter and the info class cannot
/// disagree about where a value lives.
struct InfoSlot {
    name: String,
    field_ty: String,
    py_ty: &'static str,
    doc: String,
    tuple_expr: String,
    extract_ty: String,
    unwrap: &'static str,
    /// An empty `unwrap` keeps the flattened `Option` as is.
    flatten: bool,
}

/// The tuple slots for `error`: the `status_code` / `is_transient` / `error_type` methods it
/// implements, in that order, then its `Duration` methods as whole milliseconds.
fn info_slots(error: &ErrorDef) -> Vec<InfoSlot> {
    let has = |name: &str| error.methods.iter().any(|m| m.name == name);
    let mut slots = Vec::new();
    if has("status_code") {
        slots.push(InfoSlot {
            name: "status_code".to_string(),
            field_ty: "u16".to_string(),
            py_ty: "int",
            doc: "HTTP status code for this error (0 means no associated status).".to_string(),
            tuple_expr: "e.status_code()".to_string(),
            extract_ty: "u16".to_string(),
            unwrap: "unwrap_or(0)",
            flatten: false,
        });
    }
    if has("is_transient") {
        slots.push(InfoSlot {
            name: "is_transient".to_string(),
            field_ty: "bool".to_string(),
            py_ty: "bool",
            doc: "Returns `true` if the error is transient and a retry may succeed.".to_string(),
            tuple_expr: "e.is_transient()".to_string(),
            extract_ty: "bool".to_string(),
            unwrap: "unwrap_or(false)",
            flatten: false,
        });
    }
    if has("error_type") {
        slots.push(InfoSlot {
            name: "error_type".to_string(),
            field_ty: "String".to_string(),
            py_ty: "str",
            doc: "Machine-readable error category string for matching and logging.".to_string(),
            tuple_expr: "e.error_type().to_string()".to_string(),
            extract_ty: "String".to_string(),
            unwrap: "unwrap_or_default()",
            flatten: false,
        });
    }
    for (method, optional) in duration_methods(error) {
        let ty = millis_type(optional, "u64");
        slots.push(InfoSlot {
            name: method.name.clone(),
            field_ty: ty.clone(),
            py_ty: if optional { "int | None" } else { "int" },
            doc: format!(
                "`{}` in whole milliseconds{}.",
                method.name,
                if optional { " (`None` when absent)" } else { "" }
            ),
            tuple_expr: millis_expr(&format!("e.{}()", method.name), optional, "u64"),
            extract_ty: ty,
            unwrap: if optional { "" } else { "unwrap_or(0)" },
            flatten: optional,
        });
    }
    slots
}

/// Field specs `(name, Python type annotation)` for the `{Error}Info` companion class,
/// derived from `error.methods`. `code` is always present; the rest are whitelisted
/// introspection methods the error type actually implements. Shared by
/// `gen_pyo3_error_methods_impl` (the native `#[pyclass]`) and the pyo3 backend's `.pyi`
/// stub generator so the two can never drift on which fields the info class exposes.
pub fn pyo3_error_info_field_specs(error: &ErrorDef) -> Vec<(String, String)> {
    let mut specs = vec![("code".to_string(), "int".to_string())];
    specs.extend(
        info_slots(error)
            .into_iter()
            .map(|slot| (slot.name, slot.py_ty.to_string())),
    );
    specs
}

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 slots = info_slots(error);

    let mut fields = vec!["    pub code: u32,".to_string()];
    let mut getters = vec![
        concat!(
            "    /// Stable numeric error code identifying the specific error variant\n",
            "    /// (see the crate's FFI error taxonomy).\n",
            "    #[getter]\n",
            "    fn code(&self) -> u32 {\n",
            "        self.code\n",
            "    }",
        )
        .to_string(),
    ];
    let mut ctor_fields = vec![
        "        code: args\n\
         \x20           .as_ref()\n\
         \x20           .and_then(|a| a.get_item(1).ok())\n\
         \x20           .and_then(|v| v.extract::<u32>().ok())\n\
         \x20           .unwrap_or(0),"
            .to_string(),
    ];

    for (position, slot) in slots.iter().enumerate() {
        let index = position + 2;
        let name = &slot.name;
        let ty = &slot.field_ty;
        fields.push(format!("    pub {name}: {ty},"));
        let body = if slot.field_ty == "String" {
            format!("self.{name}.clone()")
        } else {
            format!("self.{name}")
        };
        getters.push(format!(
            "    /// {}\n    #[getter]\n    fn {name}(&self) -> {ty} {{\n        {body}\n    }}",
            slot.doc
        ));
        let flatten = if slot.flatten { "\n            .flatten()" } else { "" };
        let unwrap = if slot.unwrap.is_empty() {
            String::new()
        } else {
            format!("\n            .{}", slot.unwrap)
        };
        ctor_fields.push(format!(
            "        {name}: args\n            .as_ref()\n            .and_then(|a| a.get_item({index}).ok())\n            .and_then(|v| v.extract::<{}>().ok()){flatten}{unwrap},",
            slot.extract_ty,
        ));
    }
    for method in &error.methods {
        let is_slot = matches!(method.name.as_str(), "status_code" | "is_transient" | "error_type")
            || super::duration_shape(&method.return_type).is_some();
        if !is_slot {
            getters.push(format!(
                "    // Not emitted: getter for method `{}` on `{struct_name}`",
                method.name
            ));
        }
    }

    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 tuple_shape = slots
        .iter()
        .fold("message, code".to_string(), |acc, slot| format!("{acc}, {}", slot.name));
    let last_index = slots.len() + 1;
    let free_fn = format!(
        "/// Build a `{struct_name}` from any exception raised by the `{error_name}` hierarchy.\n\
         ///\n\
         /// The converter stores `({tuple_shape})` in\n\
         /// the exception args tuple; this function extracts those values at indices 1–{last_index}.\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}")
}

/// Returns `true` when the error has whitelisted introspection methods that
/// require extended constructor args `(msg, code, status_code, is_transient, error_type)`.
pub fn pyo3_error_has_methods(error: &ErrorDef) -> bool {
    !error.methods.is_empty()
}

/// Return the name of the companion info struct for an error type.
pub fn pyo3_error_info_struct_name(error: &ErrorDef) -> String {
    format!("{}Info", error.name)
}

/// Return the name of the free function that builds the companion info struct.
pub fn pyo3_error_info_fn_name(error: &ErrorDef) -> String {
    format!("{}_info", to_snake_case(&error.name))
}

use crate::core::ir::ErrorDef;
/// Generate a `#[napi]` class struct for the error type that stores the
/// whitelisted introspection method values as fields and exposes them as methods.
///
/// Returns an empty string when `error.methods` is empty.
use ahash::AHashSet;

use super::shared::{
    duration_methods, error_variant_wildcard_pattern, millis_expr, millis_type, python_exception_name, to_snake_case,
};