1use ahash::AHashSet;
2use alef_core::ir::{ErrorDef, ErrorVariant};
3
4use crate::conversions::is_tuple_variant;
5
6fn 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
18const 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
55fn error_base_prefix(error_name: &str) -> &str {
58 error_name.strip_suffix("Error").unwrap_or(error_name)
59}
60
61pub 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 if candidate.starts_with(prefix) {
77 candidate
78 } else {
79 format!("{}{}", prefix, candidate)
80 }
81 } else {
82 candidate
83 }
84}
85
86pub 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 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 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
115pub 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 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 lines.push(format!(" _ => {}::new_err(msg),", error.name));
152 lines.push(" }".to_string());
153 lines.push("}".to_string());
154 lines.join("\n")
155}
156
157pub 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 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
184pub fn converter_fn_name(error: &ErrorDef) -> String {
186 format!("{}_to_py_err", to_snake_case(&error.name))
187}
188
189fn 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
205pub 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
224pub 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 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
257pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
259 format!("{}_to_napi_err", to_snake_case(&error.name))
260}
261
262pub 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 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 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
315pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
317 format!("{}_to_js_value", to_snake_case(&error.name))
318}
319
320pub 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 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
359pub fn php_converter_fn_name(error: &ErrorDef) -> String {
361 format!("{}_to_php_err", to_snake_case(&error.name))
362}
363
364pub 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
390pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
392 format!("{}_to_magnus_err", to_snake_case(&error.name))
393}
394
395pub 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
418pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
420 format!("{}_to_rustler_err", to_snake_case(&error.name))
421}
422
423pub 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 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
456pub 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
472pub 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
516pub 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
534fn 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 type_name[pkg_lower.len()..].to_string()
551 } else {
552 type_name.to_string()
553 }
554}
555
556pub 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 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 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 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
623pub 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 let base_parent = fallback_class.unwrap_or("Exception");
647
648 {
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 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
702fn 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
722const 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
735pub fn strip_thiserror_placeholders(template: &str) -> String {
749 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 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 let trimmed = compacted
778 .trim()
779 .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
780 .trim();
781 let cleaned = trimmed
784 .replace("()", "")
785 .replace("''", "")
786 .replace("\"\"", "")
787 .replace(" ", " ");
788 cleaned.trim().to_string()
789}
790
791pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
801 if variant_name.is_empty() {
802 return String::new();
803 }
804 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
828fn 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 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 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 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); 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 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 assert!(!output.contains("Parsing(..)"));
1025 }
1026
1027 #[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 #[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 assert!(output.contains(
1086 "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1087 ));
1088 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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[test]
1332 fn test_gen_go_error_types() {
1333 let error = sample_error();
1334 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 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 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 assert!(output.contains("ErrParseError = errors.New("));
1364 }
1365
1366 #[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 assert_eq!(files.len(), 4);
1376 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 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 #[test]
1400 fn test_gen_csharp_error_types() {
1401 let error = sample_error();
1402 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 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 assert!(
1431 files[1]
1432 .1
1433 .contains("public class ParseErrorException : ConversionErrorException")
1434 );
1435 }
1436
1437 #[test]
1442 fn test_python_exception_name_no_conflict() {
1443 assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1445 assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1447 }
1448
1449 #[test]
1450 fn test_python_exception_name_shadows_builtin() {
1451 assert_eq!(
1453 python_exception_name("Connection", "CrawlError"),
1454 "CrawlConnectionError"
1455 );
1456 assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1458 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 assert_eq!(
1469 python_exception_name("CrawlConnectionError", "CrawlError"),
1470 "CrawlConnectionError"
1471 );
1472 }
1473}