Skip to main content

alef_codegen/
error_gen.rs

1use ahash::AHashSet;
2use alef_core::ir::{ErrorDef, ErrorVariant};
3
4use crate::conversions::is_tuple_variant;
5
6/// Generate a wildcard match pattern for an error variant.
7/// Struct variants use `{ .. }`, tuple variants use `(..)`, unit variants have no suffix.
8fn error_variant_wildcard_pattern(rust_path: &str, variant: &ErrorVariant) -> String {
9    if variant.is_unit {
10        format!("{rust_path}::{}", variant.name)
11    } else if is_tuple_variant(&variant.fields) {
12        format!("{rust_path}::{}(..)", variant.name)
13    } else {
14        format!("{rust_path}::{} {{ .. }}", variant.name)
15    }
16}
17
18/// Python builtin exception names that must not be shadowed (A004 compliance).
19const PYTHON_BUILTIN_EXCEPTIONS: &[&str] = &[
20    "ConnectionError",
21    "TimeoutError",
22    "PermissionError",
23    "FileNotFoundError",
24    "ValueError",
25    "TypeError",
26    "RuntimeError",
27    "OSError",
28    "IOError",
29    "KeyError",
30    "IndexError",
31    "AttributeError",
32    "ImportError",
33    "MemoryError",
34    "OverflowError",
35    "StopIteration",
36    "RecursionError",
37    "SystemError",
38    "ReferenceError",
39    "BufferError",
40    "EOFError",
41    "LookupError",
42    "ArithmeticError",
43    "AssertionError",
44    "BlockingIOError",
45    "BrokenPipeError",
46    "ChildProcessError",
47    "FileExistsError",
48    "InterruptedError",
49    "IsADirectoryError",
50    "NotADirectoryError",
51    "ProcessLookupError",
52    "UnicodeError",
53];
54
55/// Compute a prefix from the error type name by stripping a trailing "Error" suffix.
56/// E.g. `"CrawlError"` -> `"Crawl"`, `"MyException"` -> `"MyException"`.
57fn error_base_prefix(error_name: &str) -> &str {
58    error_name.strip_suffix("Error").unwrap_or(error_name)
59}
60
61/// Return the Python exception name for a variant, avoiding shadowing of Python builtins.
62///
63/// 1. Appends `"Error"` suffix if not already present (N818 compliance).
64/// 2. If the resulting name shadows a Python builtin, prefixes it with the error type's base
65///    name. E.g. for `CrawlError::Connection` -> `ConnectionError` (shadowed) -> `CrawlConnectionError`.
66pub fn python_exception_name(variant_name: &str, error_name: &str) -> String {
67    let candidate = if variant_name.ends_with("Error") {
68        variant_name.to_string()
69    } else {
70        format!("{}Error", variant_name)
71    };
72
73    if PYTHON_BUILTIN_EXCEPTIONS.contains(&candidate.as_str()) {
74        let prefix = error_base_prefix(error_name);
75        // Avoid double-prefixing if the candidate already starts with the prefix
76        if candidate.starts_with(prefix) {
77            candidate
78        } else {
79            format!("{}{}", prefix, candidate)
80        }
81    } else {
82        candidate
83    }
84}
85
86/// Generate `pyo3::create_exception!` macros for each error variant plus the base error type.
87/// Appends "Error" suffix to variant names that don't already have it (N818 compliance).
88/// Prefixes names that would shadow Python builtins (A004 compliance).
89pub fn gen_pyo3_error_types(error: &ErrorDef, module_name: &str, seen_exceptions: &mut AHashSet<String>) -> String {
90    let mut lines = Vec::with_capacity(error.variants.len() + 2);
91    lines.push("// Error types".to_string());
92
93    // One exception per variant (with Error suffix if needed, prefixed if shadowing builtins)
94    for variant in &error.variants {
95        let variant_name = python_exception_name(&variant.name, &error.name);
96        if seen_exceptions.insert(variant_name.clone()) {
97            lines.push(format!(
98                "pyo3::create_exception!({module_name}, {}, pyo3::exceptions::PyException);",
99                variant_name
100            ));
101        }
102    }
103
104    // Base exception for the enum itself
105    if seen_exceptions.insert(error.name.clone()) {
106        lines.push(format!(
107            "pyo3::create_exception!({module_name}, {}, pyo3::exceptions::PyException);",
108            error.name
109        ));
110    }
111
112    lines.join("\n")
113}
114
115/// Generate a `to_py_err` converter function that maps each Rust error variant to a Python exception.
116/// Uses Error-suffixed names for variant exceptions (N818 compliance).
117pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
118    let rust_path = if error.rust_path.is_empty() {
119        format!("{core_import}::{}", error.name)
120    } else {
121        let normalized = error.rust_path.replace('-', "_");
122        // Paths with more than 2 segments (e.g. `spikard_core::di::error::DependencyError`)
123        // reference private internal modules that are not accessible from generated binding code.
124        // Fall back to the public re-export form `{crate}::{ErrorName}` (2 segments).
125        let segments: Vec<&str> = normalized.split("::").collect();
126        if segments.len() > 2 {
127            let crate_name = segments[0];
128            let error_name = segments[segments.len() - 1];
129            format!("{crate_name}::{error_name}")
130        } else {
131            normalized
132        }
133    };
134
135    let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
136
137    let mut lines = Vec::new();
138    lines.push(format!("/// Convert a `{rust_path}` error to a Python exception."));
139    lines.push(format!("fn {fn_name}(e: {rust_path}) -> pyo3::PyErr {{"));
140    lines.push("    let msg = e.to_string();".to_string());
141    lines.push("    #[allow(unreachable_patterns)]".to_string());
142    lines.push("    match &e {".to_string());
143
144    for variant in &error.variants {
145        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
146        let variant_exc_name = python_exception_name(&variant.name, &error.name);
147        lines.push(format!("        {pattern} => {}::new_err(msg),", variant_exc_name));
148    }
149
150    // Catch-all for cfg-gated variants not in the IR
151    lines.push(format!("        _ => {}::new_err(msg),", error.name));
152    lines.push("    }".to_string());
153    lines.push("}".to_string());
154    lines.join("\n")
155}
156
157/// Generate `m.add(...)` registration calls for each exception type.
158/// Uses Error-suffixed names for variant exceptions (N818 compliance).
159/// Prefixes names that would shadow Python builtins (A004 compliance).
160pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
161    let mut registrations = Vec::with_capacity(error.variants.len() + 1);
162
163    for variant in &error.variants {
164        let variant_exc_name = python_exception_name(&variant.name, &error.name);
165        if seen_registrations.insert(variant_exc_name.clone()) {
166            registrations.push(format!(
167                "    m.add(\"{}\", m.py().get_type::<{}>())?;",
168                variant_exc_name, variant_exc_name
169            ));
170        }
171    }
172
173    // Base exception
174    if seen_registrations.insert(error.name.clone()) {
175        registrations.push(format!(
176            "    m.add(\"{}\", m.py().get_type::<{}>())?;",
177            error.name, error.name
178        ));
179    }
180
181    registrations
182}
183
184/// Return the converter function name for a given error type.
185pub fn converter_fn_name(error: &ErrorDef) -> String {
186    format!("{}_to_py_err", to_snake_case(&error.name))
187}
188
189/// Simple CamelCase to snake_case conversion.
190fn to_snake_case(s: &str) -> String {
191    let mut result = String::with_capacity(s.len() + 4);
192    for (i, c) in s.chars().enumerate() {
193        if c.is_uppercase() {
194            if i > 0 {
195                result.push('_');
196            }
197            result.push(c.to_ascii_lowercase());
198        } else {
199            result.push(c);
200        }
201    }
202    result
203}
204
205// ---------------------------------------------------------------------------
206// NAPI (Node.js) error generation
207// ---------------------------------------------------------------------------
208
209/// Generate a `JsError` enum with string constants for each error variant name.
210pub fn gen_napi_error_types(error: &ErrorDef) -> String {
211    let mut lines = Vec::with_capacity(error.variants.len() + 4);
212    lines.push("// Error variant name constants".to_string());
213    for variant in &error.variants {
214        lines.push(format!(
215            "pub const {}_ERROR_{}: &str = \"{}\";",
216            to_screaming_snake(&error.name),
217            to_screaming_snake(&variant.name),
218            variant.name,
219        ));
220    }
221    lines.join("\n")
222}
223
224/// Generate a converter function that maps a core error to `napi::Error`.
225pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
226    let rust_path = if error.rust_path.is_empty() {
227        format!("{core_import}::{}", error.name)
228    } else {
229        error.rust_path.replace('-', "_")
230    };
231
232    let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));
233
234    let mut lines = Vec::new();
235    lines.push(format!("/// Convert a `{rust_path}` error to a NAPI error."));
236    lines.push("#[allow(dead_code)]".to_string());
237    lines.push(format!("fn {fn_name}(e: {rust_path}) -> napi::Error {{"));
238    lines.push("    let msg = e.to_string();".to_string());
239    lines.push("    #[allow(unreachable_patterns)]".to_string());
240    lines.push("    match &e {".to_string());
241
242    for variant in &error.variants {
243        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
244        lines.push(format!(
245            "        {pattern} => napi::Error::new(napi::Status::GenericFailure, format!(\"[{}] {{}}\", msg)),",
246            variant.name,
247        ));
248    }
249
250    // Catch-all for cfg-gated variants not in the IR
251    lines.push("        _ => napi::Error::new(napi::Status::GenericFailure, msg),".to_string());
252    lines.push("    }".to_string());
253    lines.push("}".to_string());
254    lines.join("\n")
255}
256
257/// Return the NAPI converter function name for a given error type.
258pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
259    format!("{}_to_napi_err", to_snake_case(&error.name))
260}
261
262// ---------------------------------------------------------------------------
263// WASM (wasm-bindgen) error generation
264// ---------------------------------------------------------------------------
265
266/// Generate a converter function that maps a core error to a `JsValue` object
267/// with `code` (string) and `message` (string) fields, plus a private
268/// `error_code` helper that returns the variant code string.
269pub fn gen_wasm_error_converter(error: &ErrorDef, core_import: &str) -> String {
270    let rust_path = if error.rust_path.is_empty() {
271        format!("{core_import}::{}", error.name)
272    } else {
273        error.rust_path.replace('-', "_")
274    };
275
276    let fn_name = format!("{}_to_js_value", to_snake_case(&error.name));
277    let code_fn_name = format!("{}_error_code", to_snake_case(&error.name));
278
279    let mut lines = Vec::new();
280
281    // error_code helper — maps each variant to a snake_case string code
282    lines.push(format!("/// Return the error code string for a `{rust_path}` variant."));
283    lines.push("#[allow(dead_code)]".to_string());
284    lines.push(format!("fn {code_fn_name}(e: &{rust_path}) -> &'static str {{"));
285    lines.push("    #[allow(unreachable_patterns)]".to_string());
286    lines.push("    match e {".to_string());
287    for variant in &error.variants {
288        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
289        let code = to_snake_case(&variant.name);
290        lines.push(format!("        {pattern} => \"{code}\","));
291    }
292    lines.push(format!("        _ => \"{}\",", to_snake_case(&error.name)));
293    lines.push("    }".to_string());
294    lines.push("}".to_string());
295
296    lines.push(String::new());
297
298    // main converter — returns a JS object { code, message }
299    lines.push(format!(
300        "/// Convert a `{rust_path}` error to a `JsValue` object with `code` and `message` fields."
301    ));
302    lines.push("#[allow(dead_code)]".to_string());
303    lines.push(format!("fn {fn_name}(e: {rust_path}) -> wasm_bindgen::JsValue {{"));
304    lines.push(format!("    let code = {code_fn_name}(&e);"));
305    lines.push("    let message = e.to_string();".to_string());
306    lines.push("    let obj = js_sys::Object::new();".to_string());
307    lines.push("    js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok();".to_string());
308    lines.push("    js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok();".to_string());
309    lines.push("    obj.into()".to_string());
310    lines.push("}".to_string());
311
312    lines.join("\n")
313}
314
315/// Return the WASM converter function name for a given error type.
316pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
317    format!("{}_to_js_value", to_snake_case(&error.name))
318}
319
320// ---------------------------------------------------------------------------
321// PHP (ext-php-rs) error generation
322// ---------------------------------------------------------------------------
323
324/// Generate a converter function that maps a core error to `PhpException`.
325pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
326    let rust_path = if error.rust_path.is_empty() {
327        format!("{core_import}::{}", error.name)
328    } else {
329        error.rust_path.replace('-', "_")
330    };
331
332    let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));
333
334    let mut lines = Vec::new();
335    lines.push(format!("/// Convert a `{rust_path}` error to a PHP exception."));
336    lines.push("#[allow(dead_code)]".to_string());
337    lines.push(format!(
338        "fn {fn_name}(e: {rust_path}) -> ext_php_rs::exception::PhpException {{"
339    ));
340    lines.push("    let msg = e.to_string();".to_string());
341    lines.push("    #[allow(unreachable_patterns)]".to_string());
342    lines.push("    match &e {".to_string());
343
344    for variant in &error.variants {
345        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
346        lines.push(format!(
347            "        {pattern} => ext_php_rs::exception::PhpException::default(format!(\"[{}] {{}}\", msg)),",
348            variant.name,
349        ));
350    }
351
352    // Catch-all for cfg-gated variants not in the IR
353    lines.push("        _ => ext_php_rs::exception::PhpException::default(msg),".to_string());
354    lines.push("    }".to_string());
355    lines.push("}".to_string());
356    lines.join("\n")
357}
358
359/// Return the PHP converter function name for a given error type.
360pub fn php_converter_fn_name(error: &ErrorDef) -> String {
361    format!("{}_to_php_err", to_snake_case(&error.name))
362}
363
364// ---------------------------------------------------------------------------
365// Magnus (Ruby) error generation
366// ---------------------------------------------------------------------------
367
368/// Generate a converter function that maps a core error to `magnus::Error`.
369pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
370    let rust_path = if error.rust_path.is_empty() {
371        format!("{core_import}::{}", error.name)
372    } else {
373        error.rust_path.replace('-', "_")
374    };
375
376    let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));
377
378    let mut lines = Vec::new();
379    lines.push(format!("/// Convert a `{rust_path}` error to a Magnus runtime error."));
380    lines.push("#[allow(dead_code)]".to_string());
381    lines.push(format!("fn {fn_name}(e: {rust_path}) -> magnus::Error {{"));
382    lines.push("    let msg = e.to_string();".to_string());
383    lines.push(
384        "    magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)".to_string(),
385    );
386    lines.push("}".to_string());
387    lines.join("\n")
388}
389
390/// Return the Magnus converter function name for a given error type.
391pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
392    format!("{}_to_magnus_err", to_snake_case(&error.name))
393}
394
395// ---------------------------------------------------------------------------
396// Rustler (Elixir) error generation
397// ---------------------------------------------------------------------------
398
399/// Generate a converter function that maps a core error to a Rustler error tuple `{:error, reason}`.
400pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
401    let rust_path = if error.rust_path.is_empty() {
402        format!("{core_import}::{}", error.name)
403    } else {
404        error.rust_path.replace('-', "_")
405    };
406
407    let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));
408
409    let mut lines = Vec::new();
410    lines.push(format!("/// Convert a `{rust_path}` error to a Rustler error string."));
411    lines.push("#[allow(dead_code)]".to_string());
412    lines.push(format!("fn {fn_name}(e: {rust_path}) -> String {{"));
413    lines.push("    e.to_string()".to_string());
414    lines.push("}".to_string());
415    lines.join("\n")
416}
417
418/// Return the Rustler converter function name for a given error type.
419pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
420    format!("{}_to_rustler_err", to_snake_case(&error.name))
421}
422
423// ---------------------------------------------------------------------------
424// FFI (C) error code generation
425// ---------------------------------------------------------------------------
426
427/// Generate a C enum of error codes plus an error-message function declaration.
428///
429/// Produces a `typedef enum` with `PREFIX_ERROR_NONE = 0` followed by one entry
430/// per variant, plus a function that returns the default message for a given code.
431pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
432    let prefix = to_screaming_snake(&error.name);
433    let prefix_lower = to_snake_case(&error.name);
434
435    let mut lines = Vec::new();
436    lines.push(format!("/// Error codes for `{}`.", error.name));
437    lines.push("typedef enum {".to_string());
438    lines.push(format!("    {}_NONE = 0,", prefix));
439
440    for (i, variant) in error.variants.iter().enumerate() {
441        let variant_screaming = to_screaming_snake(&variant.name);
442        lines.push(format!("    {}_{} = {},", prefix, variant_screaming, i + 1));
443    }
444
445    lines.push(format!("}} {}_t;\n", prefix_lower));
446
447    // Error message function
448    lines.push(format!(
449        "/// Return a static string describing the error code.\nconst char* {}_error_message({}_t code);",
450        prefix_lower, prefix_lower
451    ));
452
453    lines.join("\n")
454}
455
456// ---------------------------------------------------------------------------
457// Go error type generation
458// ---------------------------------------------------------------------------
459
460/// Generate Go sentinel errors and a structured error type for an `ErrorDef`.
461///
462/// `pkg_name` is the Go package name (e.g. `"literllm"`). When the error struct
463/// name starts with the package name (case-insensitively), the package-name
464/// prefix is stripped to avoid the revive `exported` stutter lint error
465/// (e.g. `LiterLlmError` in package `literllm` → exported as `Error`).
466pub fn gen_go_error_types(error: &ErrorDef, pkg_name: &str) -> String {
467    let mut lines = Vec::new();
468
469    // Sentinel errors — each var gets a doc comment so revive doesn't complain.
470    lines.push("var (".to_string());
471    for variant in &error.variants {
472        let err_name = format!("Err{}", variant.name);
473        let msg = variant_display_message(variant);
474        // Doc comment on the var itself (inside the var block) satisfies revive.
475        lines.push(format!("\t// {} is returned when {}.", err_name, msg));
476        lines.push(format!("\t{} = errors.New(\"{}\")", err_name, msg));
477    }
478    lines.push(")\n".to_string());
479
480    // Compute the Go type name, stripping any package-name prefix to avoid
481    // the revive stutter rule (e.g. `literllm.LiterLlmError` → `literllm.Error`).
482    let go_type_name = strip_package_prefix(&error.name, pkg_name);
483
484    // Structured error type
485    lines.push(format!("// {} is a structured error type.", go_type_name));
486    lines.push(format!("type {} struct {{", go_type_name));
487    lines.push("\tCode    string".to_string());
488    lines.push("\tMessage string".to_string());
489    lines.push("}\n".to_string());
490
491    lines.push(format!(
492        "func (e *{}) Error() string {{ return e.Message }}",
493        go_type_name
494    ));
495
496    lines.join("\n")
497}
498
499/// Strip the package-name prefix from a type name to avoid revive's stutter lint.
500///
501/// Revive reports `exported: type name will be used as pkg.PkgFoo by other packages,
502/// and that stutters` when a type name begins with the package name. This function
503/// removes the prefix when it matches (case-insensitively) so that the exported name
504/// does not repeat the package name.
505///
506/// Examples:
507/// - `("LiterLlmError", "literllm")` → `"Error"` (lowercased `literllm` is a prefix
508///   of lowercased `literllmerror`)
509/// - `("ConversionError", "converter")` → `"ConversionError"` (no match)
510fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
511    let type_lower = type_name.to_lowercase();
512    let pkg_lower = pkg_name.to_lowercase();
513    if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
514        // Retain the original casing for the suffix part.
515        type_name[pkg_lower.len()..].to_string()
516    } else {
517        type_name.to_string()
518    }
519}
520
521// ---------------------------------------------------------------------------
522// Java error type generation
523// ---------------------------------------------------------------------------
524
525/// Generate Java exception sub-classes for each error variant.
526///
527/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
528/// class followed by one per-variant exception.  The caller writes each to a
529/// separate `.java` file.
530pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
531    let mut files = Vec::with_capacity(error.variants.len() + 1);
532
533    // Base exception class
534    let base_name = format!("{}Exception", error.name);
535    let mut base = String::with_capacity(512);
536    base.push_str(&format!(
537        "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
538        package
539    ));
540    if !error.doc.is_empty() {
541        let doc = error.doc.trim_end_matches('.');
542        base.push_str(&format!("/** {}. */\n", doc));
543    }
544    base.push_str(&format!("public class {} extends Exception {{\n", base_name));
545    base.push_str(&format!(
546        "    /** Creates a new {} with the given message. */\n    public {}(final String message) {{\n        super(message);\n    }}\n\n",
547        base_name, base_name
548    ));
549    base.push_str(&format!(
550        "    /** Creates a new {} with the given message and cause. */\n    public {}(final String message, final Throwable cause) {{\n        super(message, cause);\n    }}\n",
551        base_name, base_name
552    ));
553    base.push_str("}\n");
554    files.push((base_name.clone(), base));
555
556    // Per-variant exception classes
557    for variant in &error.variants {
558        let class_name = format!("{}Exception", variant.name);
559        let mut content = String::with_capacity(512);
560        content.push_str(&format!(
561            "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
562            package
563        ));
564        if !variant.doc.is_empty() {
565            let doc = variant.doc.trim_end_matches('.');
566            content.push_str(&format!("/** {}. */\n", doc));
567        }
568        content.push_str(&format!("public class {} extends {} {{\n", class_name, base_name));
569        content.push_str(&format!(
570            "    /** Creates a new {} with the given message. */\n    public {}(final String message) {{\n        super(message);\n    }}\n\n",
571            class_name, class_name
572        ));
573        content.push_str(&format!(
574            "    /** Creates a new {} with the given message and cause. */\n    public {}(final String message, final Throwable cause) {{\n        super(message, cause);\n    }}\n",
575            class_name, class_name
576        ));
577        content.push_str("}\n");
578        files.push((class_name, content));
579    }
580
581    files
582}
583
584// ---------------------------------------------------------------------------
585// C# error type generation
586// ---------------------------------------------------------------------------
587
588/// Generate C# exception sub-classes for each error variant.
589///
590/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
591/// class followed by one per-variant exception.  The caller writes each to a
592/// separate `.cs` file.
593pub fn gen_csharp_error_types(error: &ErrorDef, namespace: &str) -> Vec<(String, String)> {
594    let mut files = Vec::with_capacity(error.variants.len() + 1);
595
596    let base_name = format!("{}Exception", error.name);
597
598    // Base exception class
599    {
600        let mut out = String::with_capacity(512);
601        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\nusing System;\n\n");
602        out.push_str(&format!("namespace {};\n\n", namespace));
603        if !error.doc.is_empty() {
604            out.push_str("/// <summary>\n");
605            for line in error.doc.lines() {
606                out.push_str(&format!("/// {}\n", line));
607            }
608            out.push_str("/// </summary>\n");
609        }
610        out.push_str(&format!("public class {} : Exception\n{{\n", base_name));
611        out.push_str(&format!(
612            "    public {}(string message) : base(message) {{ }}\n\n",
613            base_name
614        ));
615        out.push_str(&format!(
616            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
617            base_name
618        ));
619        out.push_str("}\n");
620        files.push((base_name.clone(), out));
621    }
622
623    // Per-variant exception classes
624    for variant in &error.variants {
625        let class_name = format!("{}Exception", variant.name);
626        let mut out = String::with_capacity(512);
627        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\nusing System;\n\n");
628        out.push_str(&format!("namespace {};\n\n", namespace));
629        if !variant.doc.is_empty() {
630            out.push_str("/// <summary>\n");
631            for line in variant.doc.lines() {
632                out.push_str(&format!("/// {}\n", line));
633            }
634            out.push_str("/// </summary>\n");
635        }
636        out.push_str(&format!("public class {} : {}\n{{\n", class_name, base_name));
637        out.push_str(&format!(
638            "    public {}(string message) : base(message) {{ }}\n\n",
639            class_name
640        ));
641        out.push_str(&format!(
642            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
643            class_name
644        ));
645        out.push_str("}\n");
646        files.push((class_name, out));
647    }
648
649    files
650}
651
652// ---------------------------------------------------------------------------
653// Helpers
654// ---------------------------------------------------------------------------
655
656/// Convert CamelCase to SCREAMING_SNAKE_CASE.
657fn to_screaming_snake(s: &str) -> String {
658    let mut result = String::with_capacity(s.len() + 4);
659    for (i, c) in s.chars().enumerate() {
660        if c.is_uppercase() {
661            if i > 0 {
662                result.push('_');
663            }
664            result.push(c.to_ascii_uppercase());
665        } else {
666            result.push(c.to_ascii_uppercase());
667        }
668    }
669    result
670}
671
672/// Generate a human-readable message for an error variant.
673///
674/// Uses the `message_template` if present, otherwise falls back to a
675/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
676fn variant_display_message(variant: &ErrorVariant) -> String {
677    if let Some(tmpl) = &variant.message_template {
678        // Strip format placeholders like {0}, {source}, etc.
679        let msg = tmpl
680            .replace("{0}", "")
681            .replace("{source}", "")
682            .trim_end_matches(": ")
683            .trim()
684            .to_string();
685        if msg.is_empty() {
686            to_snake_case(&variant.name).replace('_', " ")
687        } else {
688            // Go convention: error strings start with lowercase
689            let mut chars = msg.chars();
690            match chars.next() {
691                Some(c) => c.to_lowercase().to_string() + chars.as_str(),
692                None => msg,
693            }
694        }
695    } else {
696        to_snake_case(&variant.name).replace('_', " ")
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use alef_core::ir::{ErrorDef, ErrorVariant};
704
705    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
706
707    /// Helper to create a tuple-style field (e.g. `_0: String`).
708    fn tuple_field(index: usize) -> FieldDef {
709        FieldDef {
710            name: format!("_{index}"),
711            ty: TypeRef::String,
712            optional: false,
713            default: None,
714            doc: String::new(),
715            sanitized: false,
716            is_boxed: false,
717            type_rust_path: None,
718            cfg: None,
719            typed_default: None,
720            core_wrapper: CoreWrapper::None,
721            vec_inner_core_wrapper: CoreWrapper::None,
722            newtype_wrapper: None,
723        }
724    }
725
726    /// Helper to create a named struct field.
727    fn named_field(name: &str) -> FieldDef {
728        FieldDef {
729            name: name.to_string(),
730            ty: TypeRef::String,
731            optional: false,
732            default: None,
733            doc: String::new(),
734            sanitized: false,
735            is_boxed: false,
736            type_rust_path: None,
737            cfg: None,
738            typed_default: None,
739            core_wrapper: CoreWrapper::None,
740            vec_inner_core_wrapper: CoreWrapper::None,
741            newtype_wrapper: None,
742        }
743    }
744
745    fn sample_error() -> ErrorDef {
746        ErrorDef {
747            name: "ConversionError".to_string(),
748            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
749            original_rust_path: String::new(),
750            variants: vec![
751                ErrorVariant {
752                    name: "ParseError".to_string(),
753                    message_template: Some("HTML parsing error: {0}".to_string()),
754                    fields: vec![tuple_field(0)],
755                    has_source: false,
756                    has_from: false,
757                    is_unit: false,
758                    doc: String::new(),
759                },
760                ErrorVariant {
761                    name: "IoError".to_string(),
762                    message_template: Some("I/O error: {0}".to_string()),
763                    fields: vec![tuple_field(0)],
764                    has_source: false,
765                    has_from: true,
766                    is_unit: false,
767                    doc: String::new(),
768                },
769                ErrorVariant {
770                    name: "Other".to_string(),
771                    message_template: Some("Conversion error: {0}".to_string()),
772                    fields: vec![tuple_field(0)],
773                    has_source: false,
774                    has_from: false,
775                    is_unit: false,
776                    doc: String::new(),
777                },
778            ],
779            doc: "Error type for conversion operations.".to_string(),
780        }
781    }
782
783    #[test]
784    fn test_gen_error_types() {
785        let error = sample_error();
786        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
787        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
788        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
789        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
790        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
791    }
792
793    #[test]
794    fn test_gen_error_converter() {
795        let error = sample_error();
796        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
797        assert!(
798            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
799        );
800        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
801        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
802    }
803
804    #[test]
805    fn test_gen_error_registration() {
806        let error = sample_error();
807        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
808        assert_eq!(regs.len(), 4); // 3 variants + 1 base
809        assert!(regs[0].contains("\"ParseError\""));
810        assert!(regs[3].contains("\"ConversionError\""));
811    }
812
813    #[test]
814    fn test_unit_variant_pattern() {
815        let error = ErrorDef {
816            name: "MyError".to_string(),
817            rust_path: "my_crate::MyError".to_string(),
818            original_rust_path: String::new(),
819            variants: vec![ErrorVariant {
820                name: "NotFound".to_string(),
821                message_template: Some("not found".to_string()),
822                fields: vec![],
823                has_source: false,
824                has_from: false,
825                is_unit: true,
826                doc: String::new(),
827            }],
828            doc: String::new(),
829        };
830        let output = gen_pyo3_error_converter(&error, "my_crate");
831        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
832        // Ensure no (..) for unit variants
833        assert!(!output.contains("NotFound(..)"));
834    }
835
836    #[test]
837    fn test_struct_variant_pattern() {
838        let error = ErrorDef {
839            name: "MyError".to_string(),
840            rust_path: "my_crate::MyError".to_string(),
841            original_rust_path: String::new(),
842            variants: vec![ErrorVariant {
843                name: "Parsing".to_string(),
844                message_template: Some("parsing error: {message}".to_string()),
845                fields: vec![named_field("message")],
846                has_source: false,
847                has_from: false,
848                is_unit: false,
849                doc: String::new(),
850            }],
851            doc: String::new(),
852        };
853        let output = gen_pyo3_error_converter(&error, "my_crate");
854        assert!(
855            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
856            "Struct variants must use {{ .. }} pattern, got:\n{output}"
857        );
858        // Ensure no (..) for struct variants
859        assert!(!output.contains("Parsing(..)"));
860    }
861
862    // -----------------------------------------------------------------------
863    // NAPI tests
864    // -----------------------------------------------------------------------
865
866    #[test]
867    fn test_gen_napi_error_types() {
868        let error = sample_error();
869        let output = gen_napi_error_types(&error);
870        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
871        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
872        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
873    }
874
875    #[test]
876    fn test_gen_napi_error_converter() {
877        let error = sample_error();
878        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
879        assert!(
880            output
881                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
882        );
883        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
884        assert!(output.contains("[ParseError]"));
885        assert!(output.contains("[IoError]"));
886        assert!(output.contains("#[allow(dead_code)]"));
887    }
888
889    #[test]
890    fn test_napi_unit_variant() {
891        let error = ErrorDef {
892            name: "MyError".to_string(),
893            rust_path: "my_crate::MyError".to_string(),
894            original_rust_path: String::new(),
895            variants: vec![ErrorVariant {
896                name: "NotFound".to_string(),
897                message_template: None,
898                fields: vec![],
899                has_source: false,
900                has_from: false,
901                is_unit: true,
902                doc: String::new(),
903            }],
904            doc: String::new(),
905        };
906        let output = gen_napi_error_converter(&error, "my_crate");
907        assert!(output.contains("my_crate::MyError::NotFound =>"));
908        assert!(!output.contains("NotFound(..)"));
909    }
910
911    // -----------------------------------------------------------------------
912    // WASM tests
913    // -----------------------------------------------------------------------
914
915    #[test]
916    fn test_gen_wasm_error_converter() {
917        let error = sample_error();
918        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
919        // Main converter function signature
920        assert!(output.contains(
921            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
922        ));
923        // Structured object with code + message
924        assert!(output.contains("js_sys::Object::new()"));
925        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
926        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
927        assert!(output.contains("obj.into()"));
928        // error_code helper
929        assert!(
930            output
931                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
932        );
933        assert!(output.contains("\"parse_error\""));
934        assert!(output.contains("\"io_error\""));
935        assert!(output.contains("\"other\""));
936        assert!(output.contains("#[allow(dead_code)]"));
937    }
938
939    // -----------------------------------------------------------------------
940    // PHP tests
941    // -----------------------------------------------------------------------
942
943    #[test]
944    fn test_gen_php_error_converter() {
945        let error = sample_error();
946        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
947        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
948        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
949        assert!(output.contains("#[allow(dead_code)]"));
950    }
951
952    // -----------------------------------------------------------------------
953    // Magnus tests
954    // -----------------------------------------------------------------------
955
956    #[test]
957    fn test_gen_magnus_error_converter() {
958        let error = sample_error();
959        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
960        assert!(
961            output.contains(
962                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
963            )
964        );
965        assert!(
966            output.contains(
967                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
968            )
969        );
970        assert!(output.contains("#[allow(dead_code)]"));
971    }
972
973    // -----------------------------------------------------------------------
974    // Rustler tests
975    // -----------------------------------------------------------------------
976
977    #[test]
978    fn test_gen_rustler_error_converter() {
979        let error = sample_error();
980        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
981        assert!(
982            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
983        );
984        assert!(output.contains("e.to_string()"));
985        assert!(output.contains("#[allow(dead_code)]"));
986    }
987
988    // -----------------------------------------------------------------------
989    // Helper tests
990    // -----------------------------------------------------------------------
991
992    #[test]
993    fn test_to_screaming_snake() {
994        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
995        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
996        assert_eq!(to_screaming_snake("Other"), "OTHER");
997    }
998
999    // -----------------------------------------------------------------------
1000    // FFI (C) tests
1001    // -----------------------------------------------------------------------
1002
1003    #[test]
1004    fn test_gen_ffi_error_codes() {
1005        let error = sample_error();
1006        let output = gen_ffi_error_codes(&error);
1007        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
1008        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
1009        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
1010        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
1011        assert!(output.contains("conversion_error_t;"));
1012        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
1013    }
1014
1015    // -----------------------------------------------------------------------
1016    // Go tests
1017    // -----------------------------------------------------------------------
1018
1019    #[test]
1020    fn test_gen_go_error_types() {
1021        let error = sample_error();
1022        // Package name that does NOT match the error prefix — type name stays unchanged.
1023        let output = gen_go_error_types(&error, "mylib");
1024        assert!(output.contains("ErrParseError = errors.New("));
1025        assert!(output.contains("ErrIoError = errors.New("));
1026        assert!(output.contains("ErrOther = errors.New("));
1027        assert!(output.contains("type ConversionError struct {"));
1028        assert!(output.contains("Code    string"));
1029        assert!(output.contains("func (e *ConversionError) Error() string"));
1030        // Each sentinel error var should have a doc comment.
1031        assert!(output.contains("// ErrParseError is returned when"));
1032        assert!(output.contains("// ErrIoError is returned when"));
1033        assert!(output.contains("// ErrOther is returned when"));
1034    }
1035
1036    #[test]
1037    fn test_gen_go_error_types_stutter_strip() {
1038        let error = sample_error();
1039        // "conversion" package — "ConversionError" starts with "conversion" (case-insensitive)
1040        // so the exported Go type should be "Error", not "ConversionError".
1041        let output = gen_go_error_types(&error, "conversion");
1042        assert!(
1043            output.contains("type Error struct {"),
1044            "expected stutter strip, got:\n{output}"
1045        );
1046        assert!(
1047            output.contains("func (e *Error) Error() string"),
1048            "expected stutter strip, got:\n{output}"
1049        );
1050        // Sentinel vars are unaffected by stutter stripping.
1051        assert!(output.contains("ErrParseError = errors.New("));
1052    }
1053
1054    // -----------------------------------------------------------------------
1055    // Java tests
1056    // -----------------------------------------------------------------------
1057
1058    #[test]
1059    fn test_gen_java_error_types() {
1060        let error = sample_error();
1061        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
1062        // base + 3 variants
1063        assert_eq!(files.len(), 4);
1064        // Base class
1065        assert_eq!(files[0].0, "ConversionErrorException");
1066        assert!(
1067            files[0]
1068                .1
1069                .contains("public class ConversionErrorException extends Exception")
1070        );
1071        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
1072        // Variant classes
1073        assert_eq!(files[1].0, "ParseErrorException");
1074        assert!(
1075            files[1]
1076                .1
1077                .contains("public class ParseErrorException extends ConversionErrorException")
1078        );
1079        assert_eq!(files[2].0, "IoErrorException");
1080        assert_eq!(files[3].0, "OtherException");
1081    }
1082
1083    // -----------------------------------------------------------------------
1084    // C# tests
1085    // -----------------------------------------------------------------------
1086
1087    #[test]
1088    fn test_gen_csharp_error_types() {
1089        let error = sample_error();
1090        let files = gen_csharp_error_types(&error, "Kreuzberg.Test");
1091        // base + 3 variants
1092        assert_eq!(files.len(), 4);
1093        // Base class
1094        assert_eq!(files[0].0, "ConversionErrorException");
1095        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1096        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1097        // Variant classes
1098        assert_eq!(files[1].0, "ParseErrorException");
1099        assert!(
1100            files[1]
1101                .1
1102                .contains("public class ParseErrorException : ConversionErrorException")
1103        );
1104        assert_eq!(files[2].0, "IoErrorException");
1105        assert_eq!(files[3].0, "OtherException");
1106    }
1107
1108    // -----------------------------------------------------------------------
1109    // python_exception_name tests
1110    // -----------------------------------------------------------------------
1111
1112    #[test]
1113    fn test_python_exception_name_no_conflict() {
1114        // "ParseError" already ends with "Error" and is not a builtin
1115        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1116        // "Other" gets "Error" suffix, "OtherError" is not a builtin
1117        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1118    }
1119
1120    #[test]
1121    fn test_python_exception_name_shadows_builtin() {
1122        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
1123        assert_eq!(
1124            python_exception_name("Connection", "CrawlError"),
1125            "CrawlConnectionError"
1126        );
1127        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
1128        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1129        // "ConnectionError" already ends with "Error", still shadows -> prefix
1130        assert_eq!(
1131            python_exception_name("ConnectionError", "CrawlError"),
1132            "CrawlConnectionError"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_python_exception_name_no_double_prefix() {
1138        // If variant is already prefixed with the error base, don't double-prefix
1139        assert_eq!(
1140            python_exception_name("CrawlConnectionError", "CrawlError"),
1141            "CrawlConnectionError"
1142        );
1143    }
1144}