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