Skip to main content

alef_e2e/codegen/
elixir.rs

1//! Elixir e2e test generator using ExUnit.
2
3use crate::config::E2eConfig;
4use crate::escape::{escape_elixir, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::AlefConfig;
9use anyhow::Result;
10use heck::ToSnakeCase;
11use std::collections::HashMap;
12use std::fmt::Write as FmtWrite;
13use std::path::PathBuf;
14
15use super::E2eCodegen;
16
17/// Elixir e2e code generator.
18pub struct ElixirCodegen;
19
20impl E2eCodegen for ElixirCodegen {
21    fn generate(
22        &self,
23        groups: &[FixtureGroup],
24        e2e_config: &E2eConfig,
25        _alef_config: &AlefConfig,
26    ) -> Result<Vec<GeneratedFile>> {
27        let lang = self.language_name();
28        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
29
30        let mut files = Vec::new();
31
32        // Resolve call config with overrides.
33        let call = &e2e_config.call;
34        let overrides = call.overrides.get(lang);
35        let raw_module = overrides
36            .and_then(|o| o.module.as_ref())
37            .cloned()
38            .unwrap_or_else(|| call.module.clone());
39        // Convert module path to Elixir PascalCase if it looks like snake_case
40        // (e.g., "html_to_markdown" -> "HtmlToMarkdown").
41        // If the override already contains "." (e.g., "Elixir.HtmlToMarkdown"), use as-is.
42        let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
43            raw_module.clone()
44        } else {
45            elixir_module_name(&raw_module)
46        };
47        let base_function_name = overrides
48            .and_then(|o| o.function.as_ref())
49            .cloned()
50            .unwrap_or_else(|| call.function.clone());
51        // Elixir facade exports async variants with `_async` suffix when the call is async.
52        // Append the suffix only if not already present.
53        let function_name = if call.r#async && !base_function_name.ends_with("_async") {
54            format!("{base_function_name}_async")
55        } else {
56            base_function_name
57        };
58        let options_type = overrides.and_then(|o| o.options_type.clone());
59        let options_default_fn = overrides.and_then(|o| o.options_via.clone());
60        let empty_enum_fields = HashMap::new();
61        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
62        let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
63        let empty_atom_fields = std::collections::HashSet::new();
64        let handle_atom_list_fields = overrides
65            .map(|o| &o.handle_atom_list_fields)
66            .unwrap_or(&empty_atom_fields);
67        let result_var = &call.result_var;
68
69        // Resolve package config.
70        let elixir_pkg = e2e_config.resolve_package("elixir");
71        let pkg_path = elixir_pkg
72            .as_ref()
73            .and_then(|p| p.path.as_ref())
74            .cloned()
75            .unwrap_or_else(|| "../../packages/elixir".to_string());
76        // The dep atom must be a valid snake_case Elixir atom (e.g., :html_to_markdown),
77        // derived from the call module name, not the PascalCase module path.
78        let dep_atom = elixir_pkg
79            .as_ref()
80            .and_then(|p| p.name.as_ref())
81            .cloned()
82            .unwrap_or_else(|| raw_module.to_snake_case());
83        let dep_version = elixir_pkg
84            .as_ref()
85            .and_then(|p| p.version.as_ref())
86            .cloned()
87            .unwrap_or_else(|| "0.1.0".to_string());
88
89        // Generate mix.exs.
90        files.push(GeneratedFile {
91            path: output_base.join("mix.exs"),
92            content: render_mix_exs(&dep_atom, &pkg_path, &dep_version, e2e_config.dep_mode),
93            generated_header: false,
94        });
95
96        // Generate lib/e2e_elixir.ex — required so the mix project compiles.
97        files.push(GeneratedFile {
98            path: output_base.join("lib").join("e2e_elixir.ex"),
99            content: "defmodule E2eElixir do\n  @moduledoc false\nend\n".to_string(),
100            generated_header: false,
101        });
102
103        // Generate test_helper.exs.
104        files.push(GeneratedFile {
105            path: output_base.join("test").join("test_helper.exs"),
106            content: "ExUnit.start()\n".to_string(),
107            generated_header: false,
108        });
109
110        // Generate test files per category.
111        for group in groups {
112            let active: Vec<&Fixture> = group
113                .fixtures
114                .iter()
115                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
116                .collect();
117
118            if active.is_empty() {
119                continue;
120            }
121
122            let filename = format!("{}_test.exs", sanitize_filename(&group.category));
123            let field_resolver = FieldResolver::new(
124                &e2e_config.fields,
125                &e2e_config.fields_optional,
126                &e2e_config.result_fields,
127                &e2e_config.fields_array,
128            );
129            let content = render_test_file(
130                &group.category,
131                &active,
132                &module_path,
133                &function_name,
134                result_var,
135                &e2e_config.call.args,
136                &field_resolver,
137                options_type.as_deref(),
138                options_default_fn.as_deref(),
139                enum_fields,
140                handle_struct_type.as_deref(),
141                handle_atom_list_fields,
142            );
143            files.push(GeneratedFile {
144                path: output_base.join("test").join(filename),
145                content,
146                generated_header: true,
147            });
148        }
149
150        Ok(files)
151    }
152
153    fn language_name(&self) -> &'static str {
154        "elixir"
155    }
156}
157
158fn render_mix_exs(
159    dep_atom: &str,
160    pkg_path: &str,
161    dep_version: &str,
162    dep_mode: crate::config::DependencyMode,
163) -> String {
164    let mut out = String::new();
165    let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
166    let _ = writeln!(out, "  use Mix.Project");
167    let _ = writeln!(out);
168    let _ = writeln!(out, "  def project do");
169    let _ = writeln!(out, "    [");
170    let _ = writeln!(out, "      app: :e2e_elixir,");
171    let _ = writeln!(out, "      version: \"0.1.0\",");
172    let _ = writeln!(out, "      elixir: \"~> 1.14\",");
173    let _ = writeln!(out, "      deps: deps()");
174    let _ = writeln!(out, "    ]");
175    let _ = writeln!(out, "  end");
176    let _ = writeln!(out);
177    let _ = writeln!(out, "  defp deps do");
178    let _ = writeln!(out, "    [");
179    // Use a bare atom for the dep name (e.g., :html_to_markdown), not a quoted atom.
180    let dep_line = match dep_mode {
181        crate::config::DependencyMode::Registry => {
182            format!("      {{:{dep_atom}, \"{dep_version}\"}}")
183        }
184        crate::config::DependencyMode::Local => {
185            format!("      {{:{dep_atom}, path: \"{pkg_path}\"}}")
186        }
187    };
188    let _ = writeln!(out, "{dep_line}");
189    let _ = writeln!(out, "    ]");
190    let _ = writeln!(out, "  end");
191    let _ = writeln!(out, "end");
192    out
193}
194
195#[allow(clippy::too_many_arguments)]
196fn render_test_file(
197    category: &str,
198    fixtures: &[&Fixture],
199    module_path: &str,
200    function_name: &str,
201    result_var: &str,
202    args: &[crate::config::ArgMapping],
203    field_resolver: &FieldResolver,
204    options_type: Option<&str>,
205    options_default_fn: Option<&str>,
206    enum_fields: &HashMap<String, String>,
207    handle_struct_type: Option<&str>,
208    handle_atom_list_fields: &std::collections::HashSet<String>,
209) -> String {
210    let mut out = String::new();
211    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
212    let _ = writeln!(out, "# E2e tests for category: {category}");
213    let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
214    let _ = writeln!(out, "  use ExUnit.Case, async: true");
215    let _ = writeln!(out);
216
217    for (i, fixture) in fixtures.iter().enumerate() {
218        render_test_case(
219            &mut out,
220            fixture,
221            module_path,
222            function_name,
223            result_var,
224            args,
225            field_resolver,
226            options_type,
227            options_default_fn,
228            enum_fields,
229            handle_struct_type,
230            handle_atom_list_fields,
231        );
232        if i + 1 < fixtures.len() {
233            let _ = writeln!(out);
234        }
235    }
236
237    let _ = writeln!(out, "end");
238    out
239}
240
241#[allow(clippy::too_many_arguments)]
242fn render_test_case(
243    out: &mut String,
244    fixture: &Fixture,
245    module_path: &str,
246    function_name: &str,
247    result_var: &str,
248    args: &[crate::config::ArgMapping],
249    field_resolver: &FieldResolver,
250    options_type: Option<&str>,
251    options_default_fn: Option<&str>,
252    enum_fields: &HashMap<String, String>,
253    handle_struct_type: Option<&str>,
254    handle_atom_list_fields: &std::collections::HashSet<String>,
255) {
256    let test_name = sanitize_ident(&fixture.id);
257    let description = fixture.description.replace('"', "\\\"");
258
259    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
260
261    let (mut setup_lines, args_str) = build_args_and_setup(
262        &fixture.input,
263        args,
264        module_path,
265        options_type,
266        options_default_fn,
267        enum_fields,
268        &fixture.id,
269        handle_struct_type,
270        handle_atom_list_fields,
271    );
272
273    let _ = writeln!(out, "  describe \"{test_name}\" do");
274    let _ = writeln!(out, "    test \"{description}\" do");
275
276    for line in &setup_lines {
277        let _ = writeln!(out, "      {line}");
278    }
279
280    // Build visitor if present
281    let final_args = if let Some(visitor_spec) = &fixture.visitor {
282        let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
283        format!("{args_str}, {visitor_var}")
284    } else {
285        args_str
286    };
287
288    if expects_error {
289        let _ = writeln!(
290            out,
291            "      assert {{:error, _}} = {module_path}.{function_name}({final_args})"
292        );
293        let _ = writeln!(out, "    end");
294        let _ = writeln!(out, "  end");
295        return;
296    }
297
298    let _ = writeln!(
299        out,
300        "      {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
301    );
302
303    for assertion in &fixture.assertions {
304        render_assertion(out, assertion, result_var, field_resolver);
305    }
306
307    let _ = writeln!(out, "    end");
308    let _ = writeln!(out, "  end");
309}
310
311/// Build setup lines (e.g. handle creation) and the argument list for the function call.
312///
313/// Returns `(setup_lines, args_string)`.
314#[allow(clippy::too_many_arguments)]
315fn build_args_and_setup(
316    input: &serde_json::Value,
317    args: &[crate::config::ArgMapping],
318    module_path: &str,
319    options_type: Option<&str>,
320    options_default_fn: Option<&str>,
321    enum_fields: &HashMap<String, String>,
322    fixture_id: &str,
323    _handle_struct_type: Option<&str>,
324    _handle_atom_list_fields: &std::collections::HashSet<String>,
325) -> (Vec<String>, String) {
326    if args.is_empty() {
327        return (Vec::new(), json_to_elixir(input));
328    }
329
330    let mut setup_lines: Vec<String> = Vec::new();
331    let mut parts: Vec<String> = Vec::new();
332
333    for arg in args {
334        if arg.arg_type == "mock_url" {
335            setup_lines.push(format!(
336                "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
337                arg.name,
338            ));
339            parts.push(arg.name.clone());
340            continue;
341        }
342
343        if arg.arg_type == "handle" {
344            // Generate a create_{name} call using {:ok, name} = ... pattern.
345            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
346            // so that partial maps work: serde_json::from_str respects #[serde(default)].
347            let constructor_name = format!("create_{}", arg.name.to_snake_case());
348            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
349            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
350            let name = &arg.name;
351            if config_value.is_null()
352                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
353            {
354                setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
355            } else {
356                // Serialize the config map to a JSON string with Jason so that Rust can
357                // deserialize it with serde_json and apply field defaults for missing keys.
358                let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
359                let escaped = escape_elixir(&json_str);
360                setup_lines.push(format!("{name}_config = \"{escaped}\""));
361                setup_lines.push(format!(
362                    "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
363                ));
364            }
365            parts.push(arg.name.clone());
366            continue;
367        }
368
369        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
370        let val = input.get(field);
371        match val {
372            None | Some(serde_json::Value::Null) if arg.optional => {
373                // Optional arg with no fixture value: skip entirely.
374                continue;
375            }
376            None | Some(serde_json::Value::Null) => {
377                // Required arg with no fixture value: pass a language-appropriate default.
378                let default_val = match arg.arg_type.as_str() {
379                    "string" => "\"\"".to_string(),
380                    "int" | "integer" => "0".to_string(),
381                    "float" | "number" => "0.0".to_string(),
382                    "bool" | "boolean" => "false".to_string(),
383                    _ => "nil".to_string(),
384                };
385                parts.push(default_val);
386            }
387            Some(v) => {
388                // For json_object args with options_type, build a proper struct.
389                if arg.arg_type == "json_object" && !v.is_null() {
390                    if let (Some(_opts_type), Some(options_fn), Some(obj)) =
391                        (options_type, options_default_fn, v.as_object())
392                    {
393                        // Add setup line to initialize options from default function.
394                        let options_var = "options";
395                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
396
397                        // For each field in the options object, add a struct update line.
398                        for (k, vv) in obj.iter() {
399                            let snake_key = k.to_snake_case();
400                            let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
401                                if let Some(s) = vv.as_str() {
402                                    let snake_val = s.to_snake_case();
403                                    // Use atom for enum values, not string
404                                    format!(":{snake_val}")
405                                } else {
406                                    json_to_elixir(vv)
407                                }
408                            } else {
409                                json_to_elixir(vv)
410                            };
411                            setup_lines.push(format!(
412                                "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
413                            ));
414                        }
415
416                        // Push the variable name as the argument.
417                        parts.push(options_var.to_string());
418                        continue;
419                    }
420                }
421                parts.push(json_to_elixir(v));
422            }
423        }
424    }
425
426    (setup_lines, parts.join(", "))
427}
428
429/// Returns true if the field expression is a numeric/integer expression
430/// (e.g., a `length(...)` call) rather than a string.
431fn is_numeric_expr(field_expr: &str) -> bool {
432    field_expr.starts_with("length(")
433}
434
435fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
436    // Skip assertions on fields that don't exist on the result type.
437    if let Some(f) = &assertion.field {
438        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
439            let _ = writeln!(out, "      # skipped: field '{f}' not available on result type");
440            return;
441        }
442    }
443
444    let field_expr = match &assertion.field {
445        Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
446        _ => result_var.to_string(),
447    };
448
449    // Only wrap in String.trim/0 when the expression is actually a string.
450    // Numeric expressions (e.g., length(...)) must not be wrapped.
451    let is_numeric = is_numeric_expr(&field_expr);
452    let trimmed_field_expr = if is_numeric {
453        field_expr.clone()
454    } else {
455        format!("String.trim({field_expr})")
456    };
457
458    match assertion.assertion_type.as_str() {
459        "equals" => {
460            if let Some(expected) = &assertion.value {
461                let elixir_val = json_to_elixir(expected);
462                // Apply String.trim only for string comparisons, not numeric ones.
463                let is_string_expected = expected.is_string();
464                if is_string_expected && !is_numeric {
465                    let _ = writeln!(out, "      assert {trimmed_field_expr} == {elixir_val}");
466                } else {
467                    let _ = writeln!(out, "      assert {field_expr} == {elixir_val}");
468                }
469            }
470        }
471        "contains" => {
472            if let Some(expected) = &assertion.value {
473                let elixir_val = json_to_elixir(expected);
474                // Use to_string() to handle atoms (enums) as well as strings
475                let _ = writeln!(
476                    out,
477                    "      assert String.contains?(to_string({field_expr}), {elixir_val})"
478                );
479            }
480        }
481        "contains_all" => {
482            if let Some(values) = &assertion.values {
483                for val in values {
484                    let elixir_val = json_to_elixir(val);
485                    let _ = writeln!(
486                        out,
487                        "      assert String.contains?(to_string({field_expr}), {elixir_val})"
488                    );
489                }
490            }
491        }
492        "not_contains" => {
493            if let Some(expected) = &assertion.value {
494                let elixir_val = json_to_elixir(expected);
495                let _ = writeln!(
496                    out,
497                    "      refute String.contains?(to_string({field_expr}), {elixir_val})"
498                );
499            }
500        }
501        "not_empty" => {
502            let _ = writeln!(out, "      assert {field_expr} != \"\"");
503        }
504        "is_empty" => {
505            if is_numeric {
506                // length(...) == 0
507                let _ = writeln!(out, "      assert {field_expr} == 0");
508            } else {
509                // Handle nil (None) as empty
510                let _ = writeln!(out, "      assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
511            }
512        }
513        "contains_any" => {
514            if let Some(values) = &assertion.values {
515                let items: Vec<String> = values.iter().map(json_to_elixir).collect();
516                let list_str = items.join(", ");
517                let _ = writeln!(
518                    out,
519                    "      assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
520                );
521            }
522        }
523        "greater_than" => {
524            if let Some(val) = &assertion.value {
525                let elixir_val = json_to_elixir(val);
526                let _ = writeln!(out, "      assert {field_expr} > {elixir_val}");
527            }
528        }
529        "less_than" => {
530            if let Some(val) = &assertion.value {
531                let elixir_val = json_to_elixir(val);
532                let _ = writeln!(out, "      assert {field_expr} < {elixir_val}");
533            }
534        }
535        "greater_than_or_equal" => {
536            if let Some(val) = &assertion.value {
537                let elixir_val = json_to_elixir(val);
538                let _ = writeln!(out, "      assert {field_expr} >= {elixir_val}");
539            }
540        }
541        "less_than_or_equal" => {
542            if let Some(val) = &assertion.value {
543                let elixir_val = json_to_elixir(val);
544                let _ = writeln!(out, "      assert {field_expr} <= {elixir_val}");
545            }
546        }
547        "starts_with" => {
548            if let Some(expected) = &assertion.value {
549                let elixir_val = json_to_elixir(expected);
550                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
551            }
552        }
553        "ends_with" => {
554            if let Some(expected) = &assertion.value {
555                let elixir_val = json_to_elixir(expected);
556                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
557            }
558        }
559        "min_length" => {
560            if let Some(val) = &assertion.value {
561                if let Some(n) = val.as_u64() {
562                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
563                }
564            }
565        }
566        "max_length" => {
567            if let Some(val) = &assertion.value {
568                if let Some(n) = val.as_u64() {
569                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
570                }
571            }
572        }
573        "count_min" => {
574            if let Some(val) = &assertion.value {
575                if let Some(n) = val.as_u64() {
576                    let _ = writeln!(out, "      assert length({field_expr}) >= {n}");
577                }
578            }
579        }
580        "count_equals" => {
581            if let Some(val) = &assertion.value {
582                if let Some(n) = val.as_u64() {
583                    let _ = writeln!(out, "      assert length({field_expr}) == {n}");
584                }
585            }
586        }
587        "is_true" => {
588            let _ = writeln!(out, "      assert {field_expr} == true");
589        }
590        "not_error" => {
591            // Already handled — the call would fail if it returned {:error, _}.
592        }
593        "error" => {
594            // Handled at the test level.
595        }
596        other => {
597            let _ = writeln!(out, "      # TODO: unsupported assertion type: {other}");
598        }
599    }
600}
601
602/// Convert a category name to an Elixir module-safe PascalCase name.
603fn elixir_module_name(category: &str) -> String {
604    use heck::ToUpperCamelCase;
605    category.to_upper_camel_case()
606}
607
608/// Convert a `serde_json::Value` to an Elixir literal string.
609fn json_to_elixir(value: &serde_json::Value) -> String {
610    match value {
611        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
612        serde_json::Value::Bool(true) => "true".to_string(),
613        serde_json::Value::Bool(false) => "false".to_string(),
614        serde_json::Value::Number(n) => n.to_string(),
615        serde_json::Value::Null => "nil".to_string(),
616        serde_json::Value::Array(arr) => {
617            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
618            format!("[{}]", items.join(", "))
619        }
620        serde_json::Value::Object(map) => {
621            let entries: Vec<String> = map
622                .iter()
623                .map(|(k, v)| format!("\"{}\" => {}", k.to_snake_case(), json_to_elixir(v)))
624                .collect();
625            format!("%{{{}}}", entries.join(", "))
626        }
627    }
628}
629
630/// Build an Elixir visitor map and add setup line. Returns the visitor variable name.
631fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
632    use std::fmt::Write as FmtWrite;
633    let mut visitor_obj = String::new();
634    let _ = writeln!(visitor_obj, "%{{");
635    for (method_name, action) in &visitor_spec.callbacks {
636        emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
637    }
638    let _ = writeln!(visitor_obj, "    }}");
639
640    setup_lines.push(format!("visitor = {visitor_obj}"));
641    "visitor".to_string()
642}
643
644/// Emit an Elixir visitor method for a callback action.
645fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
646    use std::fmt::Write as FmtWrite;
647
648    // Elixir uses atom keys and handle_ prefix
649    let handle_method = format!("handle_{}", &method_name[6..]); // strip "visit_" prefix
650    let params = match method_name {
651        "visit_link" => "_ctx, _href, _text, _title",
652        "visit_image" => "_ctx, _src, _alt, _title",
653        "visit_heading" => "_ctx, _level, text, _id",
654        "visit_code_block" => "_ctx, _lang, _code",
655        "visit_code_inline"
656        | "visit_strong"
657        | "visit_emphasis"
658        | "visit_strikethrough"
659        | "visit_underline"
660        | "visit_subscript"
661        | "visit_superscript"
662        | "visit_mark"
663        | "visit_button"
664        | "visit_summary"
665        | "visit_figcaption"
666        | "visit_definition_term"
667        | "visit_definition_description" => "_ctx, _text",
668        "visit_text" => "_ctx, _text",
669        "visit_list_item" => "_ctx, _ordered, _marker, _text",
670        "visit_blockquote" => "_ctx, _content, _depth",
671        "visit_table_row" => "_ctx, _cells, _is_header",
672        "visit_custom_element" => "_ctx, _tag_name, _html",
673        "visit_form" => "_ctx, _action_url, _method",
674        "visit_input" => "_ctx, _input_type, _name, _value",
675        "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
676        "visit_details" => "_ctx, _is_open",
677        _ => "_ctx",
678    };
679
680    let _ = writeln!(out, "      :{handle_method} => fn({params}) ->");
681    match action {
682        CallbackAction::Skip => {
683            let _ = writeln!(out, "        :skip");
684        }
685        CallbackAction::Continue => {
686            let _ = writeln!(out, "        :continue");
687        }
688        CallbackAction::PreserveHtml => {
689            let _ = writeln!(out, "        :preserve_html");
690        }
691        CallbackAction::Custom { output } => {
692            let escaped = escape_elixir(output);
693            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
694        }
695        CallbackAction::CustomTemplate { template } => {
696            // For template, use string interpolation in Elixir (but simplified without arg binding)
697            let escaped = escape_elixir(template);
698            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
699        }
700    }
701    let _ = writeln!(out, "      end,");
702}