Skip to main content

alef/codegen/error_gen/
pyo3.rs

1pub fn gen_pyo3_error_types(error: &ErrorDef, module_name: &str, seen_exceptions: &mut AHashSet<String>) -> String {
2    let mut variant_names = Vec::new();
3    for variant in &error.variants {
4        let variant_name = python_exception_name(&variant.name, &error.name);
5        if seen_exceptions.insert(variant_name.clone()) {
6            variant_names.push(variant_name);
7        }
8    }
9
10    let include_base = seen_exceptions.insert(error.name.clone());
11
12    crate::codegen::template_env::render(
13        "error_gen/pyo3_error_types.jinja",
14        minijinja::context! {
15            variant_names => variant_names,
16            module_name => module_name,
17            error_name => error.name.as_str(),
18            include_base => include_base,
19        },
20    )
21}
22
23/// Generate a `to_py_err` converter function that maps each Rust error variant to a Python exception.
24/// Uses Error-suffixed names for variant exceptions (N818 compliance).
25///
26/// When `error.methods` is non-empty, the constructor receives a tuple
27/// `(message, code, status_code, is_transient, error_type)` so that the `#[getter]`
28/// impl generated by [`gen_pyo3_error_methods_impl`] can read them back. The message stays
29/// the message alone — the stable numeric `code` travels as its own tuple element, never
30/// interpolated into the human-facing text. (~keep)
31pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
32    let rust_path = if error.rust_path.is_empty() {
33        format!("{core_import}::{}", error.name)
34    } else {
35        let normalized = error.rust_path.replace('-', "_");
36        let segments: Vec<&str> = normalized.split("::").collect();
37        if segments.len() > 2 {
38            let crate_name = segments[0];
39            let error_name = segments[segments.len() - 1];
40            format!("{crate_name}::{error_name}")
41        } else {
42            normalized
43        }
44    };
45
46    let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
47    let has_methods = !error.methods.is_empty();
48
49    // Branch on `has_methods` here, in Rust, rather than inside the template's `{% for %}`
50    // loop: a `{%- if %}...{%- else %}...{%- endif %}` nested inside a `{%- for %}` collapses
51    // the newline between successive match arms (the leading `-` on each inner tag eats the
52    // newline emitted by the previous iteration), producing a single-line `match` body. A flat
53    // loop over pre-rendered arm strings has no such interaction. (~keep)
54    let ctor_args = |code: u32| -> String {
55        if has_methods {
56            format!("(msg, {code}u32, e.status_code(), e.is_transient(), e.error_type().to_string())")
57        } else {
58            "msg".to_string()
59        }
60    };
61
62    let mut arms = Vec::with_capacity(error.variants.len());
63    for variant in &error.variants {
64        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
65        let variant_exc_name = python_exception_name(&variant.name, &error.name);
66        let code = variant
67            .taxonomy(&error.rust_path)
68            .map_or(crate::core::ir::ApiSurface::FFI_ERROR_CODE_UNKNOWN, |taxonomy| {
69                taxonomy.code
70            });
71        arms.push(format!(
72            "{pattern} => {variant_exc_name}::new_err({}),",
73            ctor_args(code)
74        ));
75    }
76    let default_arm = format!(
77        "_ => {}::new_err({}),",
78        error.name,
79        ctor_args(crate::core::ir::ApiSurface::FFI_ERROR_CODE_UNKNOWN)
80    );
81
82    crate::codegen::template_env::render(
83        "error_gen/pyo3_error_converter.jinja",
84        minijinja::context! {
85            rust_path => rust_path.as_str(),
86            fn_name => fn_name.as_str(),
87            arms => arms,
88            default_arm => default_arm.as_str(),
89        },
90    )
91}
92
93/// Generate `m.add(...)` registration calls for each exception type.
94/// Uses Error-suffixed names for variant exceptions (N818 compliance).
95/// Prefixes names that would shadow Python builtins (A004 compliance).
96pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
97    let mut registrations = Vec::with_capacity(error.variants.len() + 1);
98
99    for variant in &error.variants {
100        let variant_exc_name = python_exception_name(&variant.name, &error.name);
101        if seen_registrations.insert(variant_exc_name.clone()) {
102            registrations.push(format!(
103                "    m.add(\"{}\", m.py().get_type::<{}>())?;",
104                variant_exc_name, variant_exc_name
105            ));
106        }
107    }
108
109    if seen_registrations.insert(error.name.clone()) {
110        registrations.push(format!(
111            "    m.add(\"{}\", m.py().get_type::<{}>())?;",
112            error.name, error.name
113        ));
114    }
115
116    registrations
117}
118
119/// Return the converter function name for a given error type.
120pub fn converter_fn_name(error: &ErrorDef) -> String {
121    format!("{}_to_py_err", to_snake_case(&error.name))
122}
123
124pub fn gen_pyo3_error_methods_impl(error: &ErrorDef) -> String {
125    if error.methods.is_empty() {
126        return String::new();
127    }
128
129    let struct_name = format!("{}Info", error.name);
130    let snake_name = to_snake_case(&error.name);
131    let fn_name = format!("{snake_name}_info");
132
133    let mut fields = vec!["    pub code: u32,".to_string()];
134    let mut getters = vec![
135        concat!(
136            "    /// Stable numeric error code identifying the specific error variant\n",
137            "    /// (see the crate's FFI error taxonomy).\n",
138            "    #[getter]\n",
139            "    fn code(&self) -> u32 {\n",
140            "        self.code\n",
141            "    }",
142        )
143        .to_string(),
144    ];
145
146    let has_status_code = error.methods.iter().any(|m| m.name == "status_code");
147    let has_is_transient = error.methods.iter().any(|m| m.name == "is_transient");
148    let has_error_type = error.methods.iter().any(|m| m.name == "error_type");
149
150    if has_status_code {
151        fields.push("    pub status_code: u16,".to_string());
152        getters.push(
153            concat!(
154                "    /// HTTP status code for this error (0 means no associated status).\n",
155                "    #[getter]\n",
156                "    fn status_code(&self) -> u16 {\n",
157                "        self.status_code\n",
158                "    }",
159            )
160            .to_string(),
161        );
162    }
163    if has_is_transient {
164        fields.push("    pub is_transient: bool,".to_string());
165        getters.push(
166            concat!(
167                "    /// Returns `true` if the error is transient and a retry may succeed.\n",
168                "    #[getter]\n",
169                "    fn is_transient(&self) -> bool {\n",
170                "        self.is_transient\n",
171                "    }",
172            )
173            .to_string(),
174        );
175    }
176    if has_error_type {
177        fields.push("    pub error_type: String,".to_string());
178        getters.push(
179            concat!(
180                "    /// Machine-readable error category string for matching and logging.\n",
181                "    #[getter]\n",
182                "    fn error_type(&self) -> String {\n",
183                "        self.error_type.clone()\n",
184                "    }",
185            )
186            .to_string(),
187        );
188    }
189    for method in &error.methods {
190        match method.name.as_str() {
191            "status_code" | "is_transient" | "error_type" => {}
192            other => getters.push(format!(
193                "    // Not emitted: getter for method `{other}` on `{struct_name}`"
194            )),
195        }
196    }
197
198    let mut ctor_fields = vec![
199        "        code: args\n\
200         \x20           .as_ref()\n\
201         \x20           .and_then(|a| a.get_item(1).ok())\n\
202         \x20           .and_then(|v| v.extract::<u32>().ok())\n\
203         \x20           .unwrap_or(0),",
204    ];
205    if has_status_code {
206        ctor_fields.push(
207            "        status_code: args\n\
208             \x20           .as_ref()\n\
209             \x20           .and_then(|a| a.get_item(2).ok())\n\
210             \x20           .and_then(|v| v.extract::<u16>().ok())\n\
211             \x20           .unwrap_or(0),",
212        );
213    }
214    if has_is_transient {
215        ctor_fields.push(
216            "        is_transient: args\n\
217             \x20           .as_ref()\n\
218             \x20           .and_then(|a| a.get_item(3).ok())\n\
219             \x20           .and_then(|v| v.extract::<bool>().ok())\n\
220             \x20           .unwrap_or(false),",
221        );
222    }
223    if has_error_type {
224        ctor_fields.push(
225            "        error_type: args\n\
226             \x20           .as_ref()\n\
227             \x20           .and_then(|a| a.get_item(4).ok())\n\
228             \x20           .and_then(|v| v.extract::<String>().ok())\n\
229             \x20           .unwrap_or_default(),",
230        );
231    }
232
233    let struct_def = format!(
234        "#[pyclass(name = \"{struct_name}\")]\npub struct {struct_name} {{\n{}\n}}",
235        fields.join("\n")
236    );
237
238    let impl_block = format!("#[pymethods]\nimpl {struct_name} {{\n{}\n}}", getters.join("\n\n"));
239
240    let free_fn = format!(
241        "/// Build a `{struct_name}` from any exception raised by the `{error_name}` hierarchy.\n\
242         ///\n\
243         /// The converter stores `(message, code, status_code, is_transient, error_type)` in\n\
244         /// the exception args tuple; this function extracts those values at indices 1–4.\n\
245         #[pyfunction]\n\
246         pub fn {fn_name}(err: pyo3::Bound<'_, pyo3::types::PyAny>) -> {struct_name} {{\n\
247             let args = err.getattr(\"args\").ok();\n\
248             {struct_name} {{\n\
249         {ctor}\n\
250             }}\n\
251         }}",
252        error_name = error.name,
253        ctor = ctor_fields.join("\n"),
254    );
255
256    format!("{struct_def}\n\n{impl_block}\n\n{free_fn}")
257}
258
259/// Returns `true` when the error has whitelisted introspection methods that
260/// require extended constructor args `(msg, code, status_code, is_transient, error_type)`.
261pub fn pyo3_error_has_methods(error: &ErrorDef) -> bool {
262    !error.methods.is_empty()
263}
264
265/// Return the name of the companion info struct for an error type.
266pub fn pyo3_error_info_struct_name(error: &ErrorDef) -> String {
267    format!("{}Info", error.name)
268}
269
270/// Return the name of the free function that builds the companion info struct.
271pub fn pyo3_error_info_fn_name(error: &ErrorDef) -> String {
272    format!("{}_info", to_snake_case(&error.name))
273}
274
275use crate::core::ir::ErrorDef;
276/// Generate a `#[napi]` class struct for the error type that stores the
277/// whitelisted introspection method values as fields and exposes them as methods.
278///
279/// Returns an empty string when `error.methods` is empty.
280use ahash::AHashSet;
281
282use super::shared::{error_variant_wildcard_pattern, python_exception_name, to_snake_case};