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. `mylib_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.
632///
633/// `fallback_class` is the name of the generic library exception class (e.g.
634/// `TreeSitterLanguagePackException`) that the base error class should extend so that
635/// callers can `catch` the general library exception and catch all typed errors.
636pub fn gen_csharp_error_types(
637    error: &ErrorDef,
638    namespace: &str,
639    fallback_class: Option<&str>,
640) -> Vec<(String, String)> {
641    let mut files = Vec::with_capacity(error.variants.len() + 1);
642
643    let base_name = format!("{}Exception", error.name);
644    // Inherit from the generic library exception when provided so that
645    // `Assert.ThrowsAny<LibException>()` catches typed errors too.
646    let base_parent = fallback_class.unwrap_or("Exception");
647
648    // Base exception class
649    {
650        let mut out = String::with_capacity(512);
651        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\n#nullable enable\n\nusing System;\n\n");
652        out.push_str(&format!("namespace {};\n\n", namespace));
653        if !error.doc.is_empty() {
654            out.push_str("/// <summary>\n");
655            for line in error.doc.lines() {
656                out.push_str(&format!("/// {}\n", line));
657            }
658            out.push_str("/// </summary>\n");
659        }
660        out.push_str(&format!("public class {} : {}\n{{\n", base_name, base_parent));
661        out.push_str(&format!(
662            "    public {}(string message) : base(message) {{ }}\n\n",
663            base_name
664        ));
665        out.push_str(&format!(
666            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
667            base_name
668        ));
669        out.push_str("}\n");
670        files.push((base_name.clone(), out));
671    }
672
673    // Per-variant exception classes
674    for variant in &error.variants {
675        let class_name = format!("{}Exception", variant.name);
676        let mut out = String::with_capacity(512);
677        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\n#nullable enable\n\nusing System;\n\n");
678        out.push_str(&format!("namespace {};\n\n", namespace));
679        if !variant.doc.is_empty() {
680            out.push_str("/// <summary>\n");
681            for line in variant.doc.lines() {
682                out.push_str(&format!("/// {}\n", line));
683            }
684            out.push_str("/// </summary>\n");
685        }
686        out.push_str(&format!("public class {} : {}\n{{\n", class_name, base_name));
687        out.push_str(&format!(
688            "    public {}(string message) : base(message) {{ }}\n\n",
689            class_name
690        ));
691        out.push_str(&format!(
692            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
693            class_name
694        ));
695        out.push_str("}\n");
696        files.push((class_name, out));
697    }
698
699    files
700}
701
702// ---------------------------------------------------------------------------
703// Helpers
704// ---------------------------------------------------------------------------
705
706/// Convert CamelCase to SCREAMING_SNAKE_CASE.
707fn to_screaming_snake(s: &str) -> String {
708    let mut result = String::with_capacity(s.len() + 4);
709    for (i, c) in s.chars().enumerate() {
710        if c.is_uppercase() {
711            if i > 0 {
712                result.push('_');
713            }
714            result.push(c.to_ascii_uppercase());
715        } else {
716            result.push(c.to_ascii_uppercase());
717        }
718    }
719    result
720}
721
722/// Well-known acronyms recognised by the doc/error renderers.
723///
724/// When emitting human-readable Display strings (e.g. for Go sentinel
725/// `errors.New("...")`), variant names like `IoError` must render as
726/// "IO error" — not "iO error" (the result of naive `lowercase first
727/// character` after `to_snake_case`).
728const TECHNICAL_ACRONYMS: &[&str] = &[
729    "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
730    "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
731    "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
732    "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
733];
734
735/// Strip `thiserror`-style `{name}` placeholders from a Display template
736/// without leaving stray punctuation.
737///
738/// Examples:
739///
740/// - `"OCR error: {message}"`           → `"OCR error"`
741/// - `"plugin error in '{plugin_name}'"` → `"plugin error"`
742/// - `"timed out after {elapsed_ms}ms (limit: {limit_ms}ms)"` → `"timed out"`
743/// - `"I/O error: {0}"`                  → `"I/O error"`
744///
745/// Used by `variant_display_message` and binding error renderers
746/// (Dart, Go, …) so the literal placeholder string never reaches
747/// the runtime.
748pub fn strip_thiserror_placeholders(template: &str) -> String {
749    // Remove every `{...}` segment.
750    let mut without_placeholders = String::with_capacity(template.len());
751    let mut depth = 0u32;
752    for ch in template.chars() {
753        match ch {
754            '{' => depth = depth.saturating_add(1),
755            '}' => depth = depth.saturating_sub(1),
756            other if depth == 0 => without_placeholders.push(other),
757            _ => {}
758        }
759    }
760    // Remove orphaned punctuation/whitespace immediately around the holes
761    // (collapse runs of whitespace, drop trailing `:`/quote runs, drop
762    // `(...)` shells that wrapped only placeholders).
763    let mut compacted = String::with_capacity(without_placeholders.len());
764    let mut last_was_space = false;
765    for ch in without_placeholders.chars() {
766        if ch.is_whitespace() {
767            if !last_was_space && !compacted.is_empty() {
768                compacted.push(' ');
769            }
770            last_was_space = true;
771        } else {
772            compacted.push(ch);
773            last_was_space = false;
774        }
775    }
776    // Trim trailing punctuation that only made sense before a placeholder.
777    let trimmed = compacted
778        .trim()
779        .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
780        .trim();
781    // If we left e.g. `"limit: ms ms"` artefacts behind, collapse stray
782    // empty parens / paired quotes.
783    let cleaned = trimmed
784        .replace("()", "")
785        .replace("''", "")
786        .replace("\"\"", "")
787        .replace("  ", " ");
788    cleaned.trim().to_string()
789}
790
791/// Convert a PascalCase variant name into a human readable phrase that
792/// preserves canonical acronyms.
793///
794/// Examples:
795/// - `"IoError"`           → `"IO error"`
796/// - `"OcrError"`          → `"OCR error"`
797/// - `"PdfParse"`          → `"PDF parse"`
798/// - `"HttpRequestFailed"` → `"HTTP request failed"`
799/// - `"Other"`             → `"other"`
800pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
801    if variant_name.is_empty() {
802        return String::new();
803    }
804    // Split into PascalCase words (each word starts with an uppercase letter).
805    let bytes = variant_name.as_bytes();
806    let mut words: Vec<&str> = Vec::new();
807    let mut start = 0usize;
808    for i in 1..bytes.len() {
809        if bytes[i].is_ascii_uppercase() {
810            words.push(&variant_name[start..i]);
811            start = i;
812        }
813    }
814    words.push(&variant_name[start..]);
815
816    let mut rendered: Vec<String> = Vec::with_capacity(words.len());
817    for word in &words {
818        let upper = word.to_ascii_uppercase();
819        if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
820            rendered.push(upper);
821        } else {
822            rendered.push(word.to_ascii_lowercase());
823        }
824    }
825    rendered.join(" ")
826}
827
828/// Generate a human-readable message for an error variant.
829///
830/// Uses the `message_template` if present, otherwise falls back to a
831/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
832fn variant_display_message(variant: &ErrorVariant) -> String {
833    if let Some(tmpl) = &variant.message_template {
834        let stripped = strip_thiserror_placeholders(tmpl);
835        if stripped.is_empty() {
836            return acronym_aware_snake_phrase(&variant.name);
837        }
838        // Preserve canonical acronyms but lowercase the first regular word so
839        // Go's `lowercase first char` convention does not corrupt `IO` → `iO`.
840        // Heuristic: if the first whitespace-delimited token is *not* already
841        // a known acronym, downcase its first character.
842        let mut tokens = stripped.splitn(2, ' ');
843        let head = tokens.next().unwrap_or("").to_string();
844        let tail = tokens.next().unwrap_or("");
845        let head_upper = head.to_ascii_uppercase();
846        let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
847            head_upper
848        } else {
849            let mut chars = head.chars();
850            match chars.next() {
851                Some(c) => c.to_lowercase().to_string() + chars.as_str(),
852                None => head,
853            }
854        };
855        if tail.is_empty() {
856            head_rendered
857        } else {
858            format!("{} {}", head_rendered, tail)
859        }
860    } else {
861        acronym_aware_snake_phrase(&variant.name)
862    }
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868    use alef_core::ir::{ErrorDef, ErrorVariant};
869
870    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
871
872    /// Helper to create a tuple-style field (e.g. `_0: String`).
873    fn tuple_field(index: usize) -> FieldDef {
874        FieldDef {
875            name: format!("_{index}"),
876            ty: TypeRef::String,
877            optional: false,
878            default: None,
879            doc: String::new(),
880            sanitized: false,
881            is_boxed: false,
882            type_rust_path: None,
883            cfg: None,
884            typed_default: None,
885            core_wrapper: CoreWrapper::None,
886            vec_inner_core_wrapper: CoreWrapper::None,
887            newtype_wrapper: None,
888        }
889    }
890
891    /// Helper to create a named struct field.
892    fn named_field(name: &str) -> FieldDef {
893        FieldDef {
894            name: name.to_string(),
895            ty: TypeRef::String,
896            optional: false,
897            default: None,
898            doc: String::new(),
899            sanitized: false,
900            is_boxed: false,
901            type_rust_path: None,
902            cfg: None,
903            typed_default: None,
904            core_wrapper: CoreWrapper::None,
905            vec_inner_core_wrapper: CoreWrapper::None,
906            newtype_wrapper: None,
907        }
908    }
909
910    fn sample_error() -> ErrorDef {
911        ErrorDef {
912            name: "ConversionError".to_string(),
913            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
914            original_rust_path: String::new(),
915            variants: vec![
916                ErrorVariant {
917                    name: "ParseError".to_string(),
918                    message_template: Some("HTML parsing error: {0}".to_string()),
919                    fields: vec![tuple_field(0)],
920                    has_source: false,
921                    has_from: false,
922                    is_unit: false,
923                    doc: String::new(),
924                },
925                ErrorVariant {
926                    name: "IoError".to_string(),
927                    message_template: Some("I/O error: {0}".to_string()),
928                    fields: vec![tuple_field(0)],
929                    has_source: false,
930                    has_from: true,
931                    is_unit: false,
932                    doc: String::new(),
933                },
934                ErrorVariant {
935                    name: "Other".to_string(),
936                    message_template: Some("Conversion error: {0}".to_string()),
937                    fields: vec![tuple_field(0)],
938                    has_source: false,
939                    has_from: false,
940                    is_unit: false,
941                    doc: String::new(),
942                },
943            ],
944            doc: "Error type for conversion operations.".to_string(),
945        }
946    }
947
948    #[test]
949    fn test_gen_error_types() {
950        let error = sample_error();
951        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
952        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
953        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
954        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
955        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
956    }
957
958    #[test]
959    fn test_gen_error_converter() {
960        let error = sample_error();
961        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
962        assert!(
963            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
964        );
965        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
966        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
967    }
968
969    #[test]
970    fn test_gen_error_registration() {
971        let error = sample_error();
972        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
973        assert_eq!(regs.len(), 4); // 3 variants + 1 base
974        assert!(regs[0].contains("\"ParseError\""));
975        assert!(regs[3].contains("\"ConversionError\""));
976    }
977
978    #[test]
979    fn test_unit_variant_pattern() {
980        let error = ErrorDef {
981            name: "MyError".to_string(),
982            rust_path: "my_crate::MyError".to_string(),
983            original_rust_path: String::new(),
984            variants: vec![ErrorVariant {
985                name: "NotFound".to_string(),
986                message_template: Some("not found".to_string()),
987                fields: vec![],
988                has_source: false,
989                has_from: false,
990                is_unit: true,
991                doc: String::new(),
992            }],
993            doc: String::new(),
994        };
995        let output = gen_pyo3_error_converter(&error, "my_crate");
996        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
997        // Ensure no (..) for unit variants
998        assert!(!output.contains("NotFound(..)"));
999    }
1000
1001    #[test]
1002    fn test_struct_variant_pattern() {
1003        let error = ErrorDef {
1004            name: "MyError".to_string(),
1005            rust_path: "my_crate::MyError".to_string(),
1006            original_rust_path: String::new(),
1007            variants: vec![ErrorVariant {
1008                name: "Parsing".to_string(),
1009                message_template: Some("parsing error: {message}".to_string()),
1010                fields: vec![named_field("message")],
1011                has_source: false,
1012                has_from: false,
1013                is_unit: false,
1014                doc: String::new(),
1015            }],
1016            doc: String::new(),
1017        };
1018        let output = gen_pyo3_error_converter(&error, "my_crate");
1019        assert!(
1020            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
1021            "Struct variants must use {{ .. }} pattern, got:\n{output}"
1022        );
1023        // Ensure no (..) for struct variants
1024        assert!(!output.contains("Parsing(..)"));
1025    }
1026
1027    // -----------------------------------------------------------------------
1028    // NAPI tests
1029    // -----------------------------------------------------------------------
1030
1031    #[test]
1032    fn test_gen_napi_error_types() {
1033        let error = sample_error();
1034        let output = gen_napi_error_types(&error);
1035        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
1036        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
1037        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
1038    }
1039
1040    #[test]
1041    fn test_gen_napi_error_converter() {
1042        let error = sample_error();
1043        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
1044        assert!(
1045            output
1046                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
1047        );
1048        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
1049        assert!(output.contains("[ParseError]"));
1050        assert!(output.contains("[IoError]"));
1051        assert!(output.contains("#[allow(dead_code)]"));
1052    }
1053
1054    #[test]
1055    fn test_napi_unit_variant() {
1056        let error = ErrorDef {
1057            name: "MyError".to_string(),
1058            rust_path: "my_crate::MyError".to_string(),
1059            original_rust_path: String::new(),
1060            variants: vec![ErrorVariant {
1061                name: "NotFound".to_string(),
1062                message_template: None,
1063                fields: vec![],
1064                has_source: false,
1065                has_from: false,
1066                is_unit: true,
1067                doc: String::new(),
1068            }],
1069            doc: String::new(),
1070        };
1071        let output = gen_napi_error_converter(&error, "my_crate");
1072        assert!(output.contains("my_crate::MyError::NotFound =>"));
1073        assert!(!output.contains("NotFound(..)"));
1074    }
1075
1076    // -----------------------------------------------------------------------
1077    // WASM tests
1078    // -----------------------------------------------------------------------
1079
1080    #[test]
1081    fn test_gen_wasm_error_converter() {
1082        let error = sample_error();
1083        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1084        // Main converter function signature
1085        assert!(output.contains(
1086            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1087        ));
1088        // Structured object with code + message
1089        assert!(output.contains("js_sys::Object::new()"));
1090        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1091        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1092        assert!(output.contains("obj.into()"));
1093        // error_code helper
1094        assert!(
1095            output
1096                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1097        );
1098        assert!(output.contains("\"parse_error\""));
1099        assert!(output.contains("\"io_error\""));
1100        assert!(output.contains("\"other\""));
1101        assert!(output.contains("#[allow(dead_code)]"));
1102    }
1103
1104    // -----------------------------------------------------------------------
1105    // PHP tests
1106    // -----------------------------------------------------------------------
1107
1108    #[test]
1109    fn test_gen_php_error_converter() {
1110        let error = sample_error();
1111        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1112        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1113        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1114        assert!(output.contains("#[allow(dead_code)]"));
1115    }
1116
1117    // -----------------------------------------------------------------------
1118    // Magnus tests
1119    // -----------------------------------------------------------------------
1120
1121    #[test]
1122    fn test_gen_magnus_error_converter() {
1123        let error = sample_error();
1124        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1125        assert!(
1126            output.contains(
1127                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1128            )
1129        );
1130        assert!(
1131            output.contains(
1132                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1133            )
1134        );
1135        assert!(output.contains("#[allow(dead_code)]"));
1136    }
1137
1138    // -----------------------------------------------------------------------
1139    // Rustler tests
1140    // -----------------------------------------------------------------------
1141
1142    #[test]
1143    fn test_gen_rustler_error_converter() {
1144        let error = sample_error();
1145        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1146        assert!(
1147            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1148        );
1149        assert!(output.contains("e.to_string()"));
1150        assert!(output.contains("#[allow(dead_code)]"));
1151    }
1152
1153    // -----------------------------------------------------------------------
1154    // Helper tests
1155    // -----------------------------------------------------------------------
1156
1157    #[test]
1158    fn test_to_screaming_snake() {
1159        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
1160        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
1161        assert_eq!(to_screaming_snake("Other"), "OTHER");
1162    }
1163
1164    #[test]
1165    fn test_strip_thiserror_placeholders_struct_field() {
1166        assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
1167        assert_eq!(
1168            strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
1169            "plugin error in"
1170        );
1171        // Multi-placeholder strings retain the surrounding prose verbatim
1172        // (minus the holes). Critical contract: no `{` / `}` survives.
1173        let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
1174        assert!(!result.contains('{'), "no braces: {result}");
1175        assert!(!result.contains('}'), "no braces: {result}");
1176        assert!(result.starts_with("extraction timed out after"), "{result}");
1177    }
1178
1179    #[test]
1180    fn test_strip_thiserror_placeholders_positional() {
1181        assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
1182        assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
1183    }
1184
1185    #[test]
1186    fn test_strip_thiserror_placeholders_no_placeholder() {
1187        assert_eq!(strip_thiserror_placeholders("not found"), "not found");
1188        assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
1189    }
1190
1191    #[test]
1192    fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
1193        assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
1194        assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
1195        assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
1196        assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
1197        assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
1198    }
1199
1200    #[test]
1201    fn test_acronym_aware_snake_phrase_plain_words() {
1202        assert_eq!(acronym_aware_snake_phrase("Other"), "other");
1203        assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
1204        assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
1205    }
1206
1207    #[test]
1208    fn test_variant_display_message_acronym_first_word() {
1209        let variant = ErrorVariant {
1210            name: "Io".to_string(),
1211            message_template: Some("I/O error: {0}".to_string()),
1212            fields: vec![tuple_field(0)],
1213            has_source: false,
1214            has_from: false,
1215            is_unit: false,
1216            doc: String::new(),
1217        };
1218        // Template "I/O error: {0}" → strip → "I/O error" → first token "I/O" not an acronym (with `/`),
1219        // so falls back to lowercase first char → "i/O error". Acceptable: at least no `{0}` leak.
1220        let msg = variant_display_message(&variant);
1221        assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
1222    }
1223
1224    #[test]
1225    fn test_variant_display_message_no_template_uses_acronyms() {
1226        let variant = ErrorVariant {
1227            name: "IoError".to_string(),
1228            message_template: None,
1229            fields: vec![],
1230            has_source: false,
1231            has_from: false,
1232            is_unit: false,
1233            doc: String::new(),
1234        };
1235        assert_eq!(variant_display_message(&variant), "IO error");
1236    }
1237
1238    #[test]
1239    fn test_variant_display_message_struct_template_no_leak() {
1240        let variant = ErrorVariant {
1241            name: "Ocr".to_string(),
1242            message_template: Some("OCR error: {message}".to_string()),
1243            fields: vec![named_field("message")],
1244            has_source: false,
1245            has_from: false,
1246            is_unit: false,
1247            doc: String::new(),
1248        };
1249        let msg = variant_display_message(&variant);
1250        assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
1251    }
1252
1253    #[test]
1254    fn test_go_sentinels_no_placeholder_leak() {
1255        let error = ErrorDef {
1256            name: "KreuzbergError".to_string(),
1257            rust_path: "kreuzberg::KreuzbergError".to_string(),
1258            original_rust_path: String::new(),
1259            variants: vec![
1260                ErrorVariant {
1261                    name: "Io".to_string(),
1262                    message_template: Some("IO error: {message}".to_string()),
1263                    fields: vec![named_field("message")],
1264                    has_source: false,
1265                    has_from: false,
1266                    is_unit: false,
1267                    doc: String::new(),
1268                },
1269                ErrorVariant {
1270                    name: "Ocr".to_string(),
1271                    message_template: Some("OCR error: {message}".to_string()),
1272                    fields: vec![named_field("message")],
1273                    has_source: false,
1274                    has_from: false,
1275                    is_unit: false,
1276                    doc: String::new(),
1277                },
1278                ErrorVariant {
1279                    name: "Timeout".to_string(),
1280                    message_template: Some(
1281                        "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
1282                    ),
1283                    fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
1284                    has_source: false,
1285                    has_from: false,
1286                    is_unit: false,
1287                    doc: String::new(),
1288                },
1289            ],
1290            doc: String::new(),
1291        };
1292        let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
1293        assert!(
1294            !output.contains('{'),
1295            "Go sentinels must not contain raw placeholders:\n{output}"
1296        );
1297        assert!(
1298            output.contains("ErrIo = errors.New(\"IO error\")"),
1299            "expected acronym-preserving Io sentinel, got:\n{output}"
1300        );
1301        assert!(
1302            output.contains("ErrOcr = errors.New(\"OCR error\")"),
1303            "expected acronym-preserving Ocr sentinel, got:\n{output}"
1304        );
1305        assert!(
1306            output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
1307            "expected timeout sentinel to start with the prose, got:\n{output}"
1308        );
1309    }
1310
1311    // -----------------------------------------------------------------------
1312    // FFI (C) tests
1313    // -----------------------------------------------------------------------
1314
1315    #[test]
1316    fn test_gen_ffi_error_codes() {
1317        let error = sample_error();
1318        let output = gen_ffi_error_codes(&error);
1319        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
1320        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
1321        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
1322        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
1323        assert!(output.contains("conversion_error_t;"));
1324        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
1325    }
1326
1327    // -----------------------------------------------------------------------
1328    // Go tests
1329    // -----------------------------------------------------------------------
1330
1331    #[test]
1332    fn test_gen_go_error_types() {
1333        let error = sample_error();
1334        // Package name that does NOT match the error prefix — type name stays unchanged.
1335        let output = gen_go_error_types(&error, "mylib");
1336        assert!(output.contains("ErrParseError = errors.New("));
1337        assert!(output.contains("ErrIoError = errors.New("));
1338        assert!(output.contains("ErrOther = errors.New("));
1339        assert!(output.contains("type ConversionError struct {"));
1340        assert!(output.contains("Code    string"));
1341        assert!(output.contains("func (e *ConversionError) Error() string"));
1342        // Each sentinel error var should have a doc comment.
1343        assert!(output.contains("// ErrParseError is returned when"));
1344        assert!(output.contains("// ErrIoError is returned when"));
1345        assert!(output.contains("// ErrOther is returned when"));
1346    }
1347
1348    #[test]
1349    fn test_gen_go_error_types_stutter_strip() {
1350        let error = sample_error();
1351        // "conversion" package — "ConversionError" starts with "conversion" (case-insensitive)
1352        // so the exported Go type should be "Error", not "ConversionError".
1353        let output = gen_go_error_types(&error, "conversion");
1354        assert!(
1355            output.contains("type Error struct {"),
1356            "expected stutter strip, got:\n{output}"
1357        );
1358        assert!(
1359            output.contains("func (e *Error) Error() string"),
1360            "expected stutter strip, got:\n{output}"
1361        );
1362        // Sentinel vars are unaffected by stutter stripping.
1363        assert!(output.contains("ErrParseError = errors.New("));
1364    }
1365
1366    // -----------------------------------------------------------------------
1367    // Java tests
1368    // -----------------------------------------------------------------------
1369
1370    #[test]
1371    fn test_gen_java_error_types() {
1372        let error = sample_error();
1373        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
1374        // base + 3 variants
1375        assert_eq!(files.len(), 4);
1376        // Base class
1377        assert_eq!(files[0].0, "ConversionErrorException");
1378        assert!(
1379            files[0]
1380                .1
1381                .contains("public class ConversionErrorException extends Exception")
1382        );
1383        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
1384        // Variant classes
1385        assert_eq!(files[1].0, "ParseErrorException");
1386        assert!(
1387            files[1]
1388                .1
1389                .contains("public class ParseErrorException extends ConversionErrorException")
1390        );
1391        assert_eq!(files[2].0, "IoErrorException");
1392        assert_eq!(files[3].0, "OtherException");
1393    }
1394
1395    // -----------------------------------------------------------------------
1396    // C# tests
1397    // -----------------------------------------------------------------------
1398
1399    #[test]
1400    fn test_gen_csharp_error_types() {
1401        let error = sample_error();
1402        // Without fallback class: base inherits from Exception.
1403        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
1404        assert_eq!(files.len(), 4);
1405        assert_eq!(files[0].0, "ConversionErrorException");
1406        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1407        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1408        assert_eq!(files[1].0, "ParseErrorException");
1409        assert!(
1410            files[1]
1411                .1
1412                .contains("public class ParseErrorException : ConversionErrorException")
1413        );
1414        assert_eq!(files[2].0, "IoErrorException");
1415        assert_eq!(files[3].0, "OtherException");
1416    }
1417
1418    #[test]
1419    fn test_gen_csharp_error_types_with_fallback() {
1420        let error = sample_error();
1421        // With fallback class: base inherits from the generic library exception.
1422        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
1423        assert_eq!(files.len(), 4);
1424        assert!(
1425            files[0]
1426                .1
1427                .contains("public class ConversionErrorException : TestLibException")
1428        );
1429        // Variant classes still inherit from the base error class, not from the fallback directly.
1430        assert!(
1431            files[1]
1432                .1
1433                .contains("public class ParseErrorException : ConversionErrorException")
1434        );
1435    }
1436
1437    // -----------------------------------------------------------------------
1438    // python_exception_name tests
1439    // -----------------------------------------------------------------------
1440
1441    #[test]
1442    fn test_python_exception_name_no_conflict() {
1443        // "ParseError" already ends with "Error" and is not a builtin
1444        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1445        // "Other" gets "Error" suffix, "OtherError" is not a builtin
1446        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1447    }
1448
1449    #[test]
1450    fn test_python_exception_name_shadows_builtin() {
1451        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
1452        assert_eq!(
1453            python_exception_name("Connection", "CrawlError"),
1454            "CrawlConnectionError"
1455        );
1456        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
1457        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1458        // "ConnectionError" already ends with "Error", still shadows -> prefix
1459        assert_eq!(
1460            python_exception_name("ConnectionError", "CrawlError"),
1461            "CrawlConnectionError"
1462        );
1463    }
1464
1465    #[test]
1466    fn test_python_exception_name_no_double_prefix() {
1467        // If variant is already prefixed with the error base, don't double-prefix
1468        assert_eq!(
1469            python_exception_name("CrawlConnectionError", "CrawlError"),
1470            "CrawlConnectionError"
1471        );
1472    }
1473}