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 sentinels = gen_go_sentinel_errors(std::slice::from_ref(error));
468    let structured = gen_go_error_struct(error, pkg_name);
469    format!("{}\n\n{}", sentinels, structured)
470}
471
472/// Generate a single consolidated `var (...)` block of Go sentinel errors
473/// across multiple `ErrorDef`s.
474///
475/// When the same variant name appears in more than one `ErrorDef` (e.g. both
476/// `GraphQLError` and `SchemaError` define `ValidationError`), the colliding
477/// const names are disambiguated by prefixing with the parent error type's
478/// stripped base name. For example, `GraphQLError::ValidationError` and
479/// `SchemaError::ValidationError` become `ErrGraphQLValidationError` and
480/// `ErrSchemaValidationError`. Variant names that are unique across all
481/// errors are emitted as plain `Err{Variant}` consts.
482pub fn gen_go_sentinel_errors(errors: &[ErrorDef]) -> String {
483    if errors.is_empty() {
484        return String::new();
485    }
486    let mut variant_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
487    for err in errors {
488        for v in &err.variants {
489            *variant_counts.entry(v.name.as_str()).or_insert(0) += 1;
490        }
491    }
492    let mut seen = std::collections::HashSet::new();
493    let mut lines = Vec::new();
494    lines.push("var (".to_string());
495    for err in errors {
496        let parent_base = error_base_prefix(&err.name);
497        for variant in &err.variants {
498            let collides = variant_counts.get(variant.name.as_str()).copied().unwrap_or(0) > 1;
499            let const_name = if collides {
500                format!("Err{}{}", parent_base, variant.name)
501            } else {
502                format!("Err{}", variant.name)
503            };
504            if !seen.insert(const_name.clone()) {
505                continue;
506            }
507            let msg = variant_display_message(variant);
508            lines.push(format!("\t// {} is returned when {}.", const_name, msg));
509            lines.push(format!("\t{} = errors.New(\"{}\")", const_name, msg));
510        }
511    }
512    lines.push(")".to_string());
513    lines.join("\n")
514}
515
516/// Generate the structured error type (struct + Error() method) for a single
517/// error definition. Sentinel errors are emitted separately by
518/// [`gen_go_sentinel_errors`].
519pub fn gen_go_error_struct(error: &ErrorDef, pkg_name: &str) -> String {
520    let go_type_name = strip_package_prefix(&error.name, pkg_name);
521    let mut lines = Vec::new();
522    lines.push(format!("// {} is a structured error type.", go_type_name));
523    lines.push(format!("type {} struct {{", go_type_name));
524    lines.push("\tCode    string".to_string());
525    lines.push("\tMessage string".to_string());
526    lines.push("}\n".to_string());
527    lines.push(format!(
528        "func (e *{}) Error() string {{ return e.Message }}",
529        go_type_name
530    ));
531    lines.join("\n")
532}
533
534/// Strip the package-name prefix from a type name to avoid revive's stutter lint.
535///
536/// Revive reports `exported: type name will be used as pkg.PkgFoo by other packages,
537/// and that stutters` when a type name begins with the package name. This function
538/// removes the prefix when it matches (case-insensitively) so that the exported name
539/// does not repeat the package name.
540///
541/// Examples:
542/// - `("LiterLlmError", "literllm")` → `"Error"` (lowercased `literllm` is a prefix
543///   of lowercased `literllmerror`)
544/// - `("ConversionError", "converter")` → `"ConversionError"` (no match)
545fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
546    let type_lower = type_name.to_lowercase();
547    let pkg_lower = pkg_name.to_lowercase();
548    if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
549        // Retain the original casing for the suffix part.
550        type_name[pkg_lower.len()..].to_string()
551    } else {
552        type_name.to_string()
553    }
554}
555
556// ---------------------------------------------------------------------------
557// Java error type generation
558// ---------------------------------------------------------------------------
559
560/// Generate Java exception sub-classes for each error variant.
561///
562/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
563/// class followed by one per-variant exception.  The caller writes each to a
564/// separate `.java` file.
565pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
566    let mut files = Vec::with_capacity(error.variants.len() + 1);
567
568    // Base exception class
569    let base_name = format!("{}Exception", error.name);
570    let mut base = String::with_capacity(512);
571    base.push_str(&format!(
572        "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
573        package
574    ));
575    if !error.doc.is_empty() {
576        // Multi-line Rust doc strings must be emitted as a proper Javadoc
577        // block (one `* ` per line) — not as `/** <doc>. */` with embedded
578        // newlines, which forces spotless to reformat and leaves trailing
579        // whitespace on the blank-line `* ` rows. The blank-line case emits
580        // ` *\n` (no trailing space), so prek's `trailing-whitespace` hook
581        // and `alef-verify`'s embedded hash agree.
582        crate::doc_emission::emit_javadoc(&mut base, &error.doc, "");
583    }
584    base.push_str(&format!("public class {} extends Exception {{\n", base_name));
585    base.push_str(&format!(
586        "    /** Creates a new {} with the given message. */\n    public {}(final String message) {{\n        super(message);\n    }}\n\n",
587        base_name, base_name
588    ));
589    base.push_str(&format!(
590        "    /** Creates a new {} with the given message and cause. */\n    public {}(final String message, final Throwable cause) {{\n        super(message, cause);\n    }}\n",
591        base_name, base_name
592    ));
593    base.push_str("}\n");
594    files.push((base_name.clone(), base));
595
596    // Per-variant exception classes
597    for variant in &error.variants {
598        let class_name = format!("{}Exception", variant.name);
599        let mut content = String::with_capacity(512);
600        content.push_str(&format!(
601            "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
602            package
603        ));
604        if !variant.doc.is_empty() {
605            crate::doc_emission::emit_javadoc(&mut content, &variant.doc, "");
606        }
607        content.push_str(&format!("public class {} extends {} {{\n", class_name, base_name));
608        content.push_str(&format!(
609            "    /** Creates a new {} with the given message. */\n    public {}(final String message) {{\n        super(message);\n    }}\n\n",
610            class_name, class_name
611        ));
612        content.push_str(&format!(
613            "    /** Creates a new {} with the given message and cause. */\n    public {}(final String message, final Throwable cause) {{\n        super(message, cause);\n    }}\n",
614            class_name, class_name
615        ));
616        content.push_str("}\n");
617        files.push((class_name, content));
618    }
619
620    files
621}
622
623// ---------------------------------------------------------------------------
624// C# error type generation
625// ---------------------------------------------------------------------------
626
627/// Generate C# exception sub-classes for each error variant.
628///
629/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
630/// class followed by one per-variant exception.  The caller writes each to a
631/// separate `.cs` file.
632pub fn gen_csharp_error_types(error: &ErrorDef, namespace: &str) -> Vec<(String, String)> {
633    let mut files = Vec::with_capacity(error.variants.len() + 1);
634
635    let base_name = format!("{}Exception", error.name);
636
637    // Base exception class
638    {
639        let mut out = String::with_capacity(512);
640        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\n#nullable enable\n\nusing System;\n\n");
641        out.push_str(&format!("namespace {};\n\n", namespace));
642        if !error.doc.is_empty() {
643            out.push_str("/// <summary>\n");
644            for line in error.doc.lines() {
645                out.push_str(&format!("/// {}\n", line));
646            }
647            out.push_str("/// </summary>\n");
648        }
649        out.push_str(&format!("public class {} : Exception\n{{\n", base_name));
650        out.push_str(&format!(
651            "    public {}(string message) : base(message) {{ }}\n\n",
652            base_name
653        ));
654        out.push_str(&format!(
655            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
656            base_name
657        ));
658        out.push_str("}\n");
659        files.push((base_name.clone(), out));
660    }
661
662    // Per-variant exception classes
663    for variant in &error.variants {
664        let class_name = format!("{}Exception", variant.name);
665        let mut out = String::with_capacity(512);
666        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\n#nullable enable\n\nusing System;\n\n");
667        out.push_str(&format!("namespace {};\n\n", namespace));
668        if !variant.doc.is_empty() {
669            out.push_str("/// <summary>\n");
670            for line in variant.doc.lines() {
671                out.push_str(&format!("/// {}\n", line));
672            }
673            out.push_str("/// </summary>\n");
674        }
675        out.push_str(&format!("public class {} : {}\n{{\n", class_name, base_name));
676        out.push_str(&format!(
677            "    public {}(string message) : base(message) {{ }}\n\n",
678            class_name
679        ));
680        out.push_str(&format!(
681            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
682            class_name
683        ));
684        out.push_str("}\n");
685        files.push((class_name, out));
686    }
687
688    files
689}
690
691// ---------------------------------------------------------------------------
692// Helpers
693// ---------------------------------------------------------------------------
694
695/// Convert CamelCase to SCREAMING_SNAKE_CASE.
696fn to_screaming_snake(s: &str) -> String {
697    let mut result = String::with_capacity(s.len() + 4);
698    for (i, c) in s.chars().enumerate() {
699        if c.is_uppercase() {
700            if i > 0 {
701                result.push('_');
702            }
703            result.push(c.to_ascii_uppercase());
704        } else {
705            result.push(c.to_ascii_uppercase());
706        }
707    }
708    result
709}
710
711/// Generate a human-readable message for an error variant.
712///
713/// Uses the `message_template` if present, otherwise falls back to a
714/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
715fn variant_display_message(variant: &ErrorVariant) -> String {
716    if let Some(tmpl) = &variant.message_template {
717        // Strip format placeholders like {0}, {source}, etc.
718        let msg = tmpl
719            .replace("{0}", "")
720            .replace("{source}", "")
721            .trim_end_matches(": ")
722            .trim()
723            .to_string();
724        if msg.is_empty() {
725            to_snake_case(&variant.name).replace('_', " ")
726        } else {
727            // Go convention: error strings start with lowercase
728            let mut chars = msg.chars();
729            match chars.next() {
730                Some(c) => c.to_lowercase().to_string() + chars.as_str(),
731                None => msg,
732            }
733        }
734    } else {
735        to_snake_case(&variant.name).replace('_', " ")
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use alef_core::ir::{ErrorDef, ErrorVariant};
743
744    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
745
746    /// Helper to create a tuple-style field (e.g. `_0: String`).
747    fn tuple_field(index: usize) -> FieldDef {
748        FieldDef {
749            name: format!("_{index}"),
750            ty: TypeRef::String,
751            optional: false,
752            default: None,
753            doc: String::new(),
754            sanitized: false,
755            is_boxed: false,
756            type_rust_path: None,
757            cfg: None,
758            typed_default: None,
759            core_wrapper: CoreWrapper::None,
760            vec_inner_core_wrapper: CoreWrapper::None,
761            newtype_wrapper: None,
762        }
763    }
764
765    /// Helper to create a named struct field.
766    fn named_field(name: &str) -> FieldDef {
767        FieldDef {
768            name: name.to_string(),
769            ty: TypeRef::String,
770            optional: false,
771            default: None,
772            doc: String::new(),
773            sanitized: false,
774            is_boxed: false,
775            type_rust_path: None,
776            cfg: None,
777            typed_default: None,
778            core_wrapper: CoreWrapper::None,
779            vec_inner_core_wrapper: CoreWrapper::None,
780            newtype_wrapper: None,
781        }
782    }
783
784    fn sample_error() -> ErrorDef {
785        ErrorDef {
786            name: "ConversionError".to_string(),
787            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
788            original_rust_path: String::new(),
789            variants: vec![
790                ErrorVariant {
791                    name: "ParseError".to_string(),
792                    message_template: Some("HTML parsing error: {0}".to_string()),
793                    fields: vec![tuple_field(0)],
794                    has_source: false,
795                    has_from: false,
796                    is_unit: false,
797                    doc: String::new(),
798                },
799                ErrorVariant {
800                    name: "IoError".to_string(),
801                    message_template: Some("I/O error: {0}".to_string()),
802                    fields: vec![tuple_field(0)],
803                    has_source: false,
804                    has_from: true,
805                    is_unit: false,
806                    doc: String::new(),
807                },
808                ErrorVariant {
809                    name: "Other".to_string(),
810                    message_template: Some("Conversion error: {0}".to_string()),
811                    fields: vec![tuple_field(0)],
812                    has_source: false,
813                    has_from: false,
814                    is_unit: false,
815                    doc: String::new(),
816                },
817            ],
818            doc: "Error type for conversion operations.".to_string(),
819        }
820    }
821
822    #[test]
823    fn test_gen_error_types() {
824        let error = sample_error();
825        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
826        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
827        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
828        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
829        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
830    }
831
832    #[test]
833    fn test_gen_error_converter() {
834        let error = sample_error();
835        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
836        assert!(
837            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
838        );
839        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
840        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
841    }
842
843    #[test]
844    fn test_gen_error_registration() {
845        let error = sample_error();
846        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
847        assert_eq!(regs.len(), 4); // 3 variants + 1 base
848        assert!(regs[0].contains("\"ParseError\""));
849        assert!(regs[3].contains("\"ConversionError\""));
850    }
851
852    #[test]
853    fn test_unit_variant_pattern() {
854        let error = ErrorDef {
855            name: "MyError".to_string(),
856            rust_path: "my_crate::MyError".to_string(),
857            original_rust_path: String::new(),
858            variants: vec![ErrorVariant {
859                name: "NotFound".to_string(),
860                message_template: Some("not found".to_string()),
861                fields: vec![],
862                has_source: false,
863                has_from: false,
864                is_unit: true,
865                doc: String::new(),
866            }],
867            doc: String::new(),
868        };
869        let output = gen_pyo3_error_converter(&error, "my_crate");
870        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
871        // Ensure no (..) for unit variants
872        assert!(!output.contains("NotFound(..)"));
873    }
874
875    #[test]
876    fn test_struct_variant_pattern() {
877        let error = ErrorDef {
878            name: "MyError".to_string(),
879            rust_path: "my_crate::MyError".to_string(),
880            original_rust_path: String::new(),
881            variants: vec![ErrorVariant {
882                name: "Parsing".to_string(),
883                message_template: Some("parsing error: {message}".to_string()),
884                fields: vec![named_field("message")],
885                has_source: false,
886                has_from: false,
887                is_unit: false,
888                doc: String::new(),
889            }],
890            doc: String::new(),
891        };
892        let output = gen_pyo3_error_converter(&error, "my_crate");
893        assert!(
894            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
895            "Struct variants must use {{ .. }} pattern, got:\n{output}"
896        );
897        // Ensure no (..) for struct variants
898        assert!(!output.contains("Parsing(..)"));
899    }
900
901    // -----------------------------------------------------------------------
902    // NAPI tests
903    // -----------------------------------------------------------------------
904
905    #[test]
906    fn test_gen_napi_error_types() {
907        let error = sample_error();
908        let output = gen_napi_error_types(&error);
909        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
910        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
911        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
912    }
913
914    #[test]
915    fn test_gen_napi_error_converter() {
916        let error = sample_error();
917        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
918        assert!(
919            output
920                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
921        );
922        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
923        assert!(output.contains("[ParseError]"));
924        assert!(output.contains("[IoError]"));
925        assert!(output.contains("#[allow(dead_code)]"));
926    }
927
928    #[test]
929    fn test_napi_unit_variant() {
930        let error = ErrorDef {
931            name: "MyError".to_string(),
932            rust_path: "my_crate::MyError".to_string(),
933            original_rust_path: String::new(),
934            variants: vec![ErrorVariant {
935                name: "NotFound".to_string(),
936                message_template: None,
937                fields: vec![],
938                has_source: false,
939                has_from: false,
940                is_unit: true,
941                doc: String::new(),
942            }],
943            doc: String::new(),
944        };
945        let output = gen_napi_error_converter(&error, "my_crate");
946        assert!(output.contains("my_crate::MyError::NotFound =>"));
947        assert!(!output.contains("NotFound(..)"));
948    }
949
950    // -----------------------------------------------------------------------
951    // WASM tests
952    // -----------------------------------------------------------------------
953
954    #[test]
955    fn test_gen_wasm_error_converter() {
956        let error = sample_error();
957        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
958        // Main converter function signature
959        assert!(output.contains(
960            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
961        ));
962        // Structured object with code + message
963        assert!(output.contains("js_sys::Object::new()"));
964        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
965        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
966        assert!(output.contains("obj.into()"));
967        // error_code helper
968        assert!(
969            output
970                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
971        );
972        assert!(output.contains("\"parse_error\""));
973        assert!(output.contains("\"io_error\""));
974        assert!(output.contains("\"other\""));
975        assert!(output.contains("#[allow(dead_code)]"));
976    }
977
978    // -----------------------------------------------------------------------
979    // PHP tests
980    // -----------------------------------------------------------------------
981
982    #[test]
983    fn test_gen_php_error_converter() {
984        let error = sample_error();
985        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
986        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
987        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
988        assert!(output.contains("#[allow(dead_code)]"));
989    }
990
991    // -----------------------------------------------------------------------
992    // Magnus tests
993    // -----------------------------------------------------------------------
994
995    #[test]
996    fn test_gen_magnus_error_converter() {
997        let error = sample_error();
998        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
999        assert!(
1000            output.contains(
1001                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1002            )
1003        );
1004        assert!(
1005            output.contains(
1006                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1007            )
1008        );
1009        assert!(output.contains("#[allow(dead_code)]"));
1010    }
1011
1012    // -----------------------------------------------------------------------
1013    // Rustler tests
1014    // -----------------------------------------------------------------------
1015
1016    #[test]
1017    fn test_gen_rustler_error_converter() {
1018        let error = sample_error();
1019        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1020        assert!(
1021            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1022        );
1023        assert!(output.contains("e.to_string()"));
1024        assert!(output.contains("#[allow(dead_code)]"));
1025    }
1026
1027    // -----------------------------------------------------------------------
1028    // Helper tests
1029    // -----------------------------------------------------------------------
1030
1031    #[test]
1032    fn test_to_screaming_snake() {
1033        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
1034        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
1035        assert_eq!(to_screaming_snake("Other"), "OTHER");
1036    }
1037
1038    // -----------------------------------------------------------------------
1039    // FFI (C) tests
1040    // -----------------------------------------------------------------------
1041
1042    #[test]
1043    fn test_gen_ffi_error_codes() {
1044        let error = sample_error();
1045        let output = gen_ffi_error_codes(&error);
1046        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
1047        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
1048        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
1049        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
1050        assert!(output.contains("conversion_error_t;"));
1051        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
1052    }
1053
1054    // -----------------------------------------------------------------------
1055    // Go tests
1056    // -----------------------------------------------------------------------
1057
1058    #[test]
1059    fn test_gen_go_error_types() {
1060        let error = sample_error();
1061        // Package name that does NOT match the error prefix — type name stays unchanged.
1062        let output = gen_go_error_types(&error, "mylib");
1063        assert!(output.contains("ErrParseError = errors.New("));
1064        assert!(output.contains("ErrIoError = errors.New("));
1065        assert!(output.contains("ErrOther = errors.New("));
1066        assert!(output.contains("type ConversionError struct {"));
1067        assert!(output.contains("Code    string"));
1068        assert!(output.contains("func (e *ConversionError) Error() string"));
1069        // Each sentinel error var should have a doc comment.
1070        assert!(output.contains("// ErrParseError is returned when"));
1071        assert!(output.contains("// ErrIoError is returned when"));
1072        assert!(output.contains("// ErrOther is returned when"));
1073    }
1074
1075    #[test]
1076    fn test_gen_go_error_types_stutter_strip() {
1077        let error = sample_error();
1078        // "conversion" package — "ConversionError" starts with "conversion" (case-insensitive)
1079        // so the exported Go type should be "Error", not "ConversionError".
1080        let output = gen_go_error_types(&error, "conversion");
1081        assert!(
1082            output.contains("type Error struct {"),
1083            "expected stutter strip, got:\n{output}"
1084        );
1085        assert!(
1086            output.contains("func (e *Error) Error() string"),
1087            "expected stutter strip, got:\n{output}"
1088        );
1089        // Sentinel vars are unaffected by stutter stripping.
1090        assert!(output.contains("ErrParseError = errors.New("));
1091    }
1092
1093    // -----------------------------------------------------------------------
1094    // Java tests
1095    // -----------------------------------------------------------------------
1096
1097    #[test]
1098    fn test_gen_java_error_types() {
1099        let error = sample_error();
1100        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
1101        // base + 3 variants
1102        assert_eq!(files.len(), 4);
1103        // Base class
1104        assert_eq!(files[0].0, "ConversionErrorException");
1105        assert!(
1106            files[0]
1107                .1
1108                .contains("public class ConversionErrorException extends Exception")
1109        );
1110        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
1111        // Variant classes
1112        assert_eq!(files[1].0, "ParseErrorException");
1113        assert!(
1114            files[1]
1115                .1
1116                .contains("public class ParseErrorException extends ConversionErrorException")
1117        );
1118        assert_eq!(files[2].0, "IoErrorException");
1119        assert_eq!(files[3].0, "OtherException");
1120    }
1121
1122    // -----------------------------------------------------------------------
1123    // C# tests
1124    // -----------------------------------------------------------------------
1125
1126    #[test]
1127    fn test_gen_csharp_error_types() {
1128        let error = sample_error();
1129        let files = gen_csharp_error_types(&error, "Kreuzberg.Test");
1130        // base + 3 variants
1131        assert_eq!(files.len(), 4);
1132        // Base class
1133        assert_eq!(files[0].0, "ConversionErrorException");
1134        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1135        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1136        // Variant classes
1137        assert_eq!(files[1].0, "ParseErrorException");
1138        assert!(
1139            files[1]
1140                .1
1141                .contains("public class ParseErrorException : ConversionErrorException")
1142        );
1143        assert_eq!(files[2].0, "IoErrorException");
1144        assert_eq!(files[3].0, "OtherException");
1145    }
1146
1147    // -----------------------------------------------------------------------
1148    // python_exception_name tests
1149    // -----------------------------------------------------------------------
1150
1151    #[test]
1152    fn test_python_exception_name_no_conflict() {
1153        // "ParseError" already ends with "Error" and is not a builtin
1154        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1155        // "Other" gets "Error" suffix, "OtherError" is not a builtin
1156        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1157    }
1158
1159    #[test]
1160    fn test_python_exception_name_shadows_builtin() {
1161        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
1162        assert_eq!(
1163            python_exception_name("Connection", "CrawlError"),
1164            "CrawlConnectionError"
1165        );
1166        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
1167        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1168        // "ConnectionError" already ends with "Error", still shadows -> prefix
1169        assert_eq!(
1170            python_exception_name("ConnectionError", "CrawlError"),
1171            "CrawlConnectionError"
1172        );
1173    }
1174
1175    #[test]
1176    fn test_python_exception_name_no_double_prefix() {
1177        // If variant is already prefixed with the error base, don't double-prefix
1178        assert_eq!(
1179            python_exception_name("CrawlConnectionError", "CrawlError"),
1180            "CrawlConnectionError"
1181        );
1182    }
1183}