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::{
7    Assertion, CallbackAction, Fixture, FixtureGroup, HttpExpectedResponse, HttpFixture, HttpRequest,
8};
9use alef_core::backend::GeneratedFile;
10use alef_core::config::AlefConfig;
11use anyhow::Result;
12use heck::ToSnakeCase;
13use std::collections::HashMap;
14use std::fmt::Write as FmtWrite;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18
19/// Elixir e2e code generator.
20pub struct ElixirCodegen;
21
22impl E2eCodegen for ElixirCodegen {
23    fn generate(
24        &self,
25        groups: &[FixtureGroup],
26        e2e_config: &E2eConfig,
27        _alef_config: &AlefConfig,
28    ) -> Result<Vec<GeneratedFile>> {
29        let lang = self.language_name();
30        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
31
32        let mut files = Vec::new();
33
34        // Resolve call config with overrides.
35        let call = &e2e_config.call;
36        let overrides = call.overrides.get(lang);
37        let raw_module = overrides
38            .and_then(|o| o.module.as_ref())
39            .cloned()
40            .unwrap_or_else(|| call.module.clone());
41        // Convert module path to Elixir PascalCase if it looks like snake_case
42        // (e.g., "html_to_markdown" -> "HtmlToMarkdown").
43        // If the override already contains "." (e.g., "Elixir.HtmlToMarkdown"), use as-is.
44        let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
45            raw_module.clone()
46        } else {
47            elixir_module_name(&raw_module)
48        };
49        let base_function_name = overrides
50            .and_then(|o| o.function.as_ref())
51            .cloned()
52            .unwrap_or_else(|| call.function.clone());
53        // Elixir facade exports async variants with `_async` suffix when the call is async.
54        // Append the suffix only if not already present.
55        let function_name = if call.r#async && !base_function_name.ends_with("_async") {
56            format!("{base_function_name}_async")
57        } else {
58            base_function_name
59        };
60        let options_type = overrides.and_then(|o| o.options_type.clone());
61        let options_default_fn = overrides.and_then(|o| o.options_via.clone());
62        let empty_enum_fields = HashMap::new();
63        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
64        let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
65        let empty_atom_fields = std::collections::HashSet::new();
66        let handle_atom_list_fields = overrides
67            .map(|o| &o.handle_atom_list_fields)
68            .unwrap_or(&empty_atom_fields);
69        let result_var = &call.result_var;
70
71        // Resolve package config.
72        let elixir_pkg = e2e_config.resolve_package("elixir");
73        let pkg_path = elixir_pkg
74            .as_ref()
75            .and_then(|p| p.path.as_ref())
76            .cloned()
77            .unwrap_or_else(|| "../../packages/elixir".to_string());
78        // The dep atom must be a valid snake_case Elixir atom (e.g., :html_to_markdown),
79        // derived from the call module name, not the PascalCase module path.
80        let dep_atom = elixir_pkg
81            .as_ref()
82            .and_then(|p| p.name.as_ref())
83            .cloned()
84            .unwrap_or_else(|| raw_module.to_snake_case());
85        let dep_version = elixir_pkg
86            .as_ref()
87            .and_then(|p| p.version.as_ref())
88            .cloned()
89            .unwrap_or_else(|| "0.1.0".to_string());
90
91        // Check if any fixture in any group is an HTTP test.
92        let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
93
94        // Generate mix.exs.
95        files.push(GeneratedFile {
96            path: output_base.join("mix.exs"),
97            content: render_mix_exs(&dep_atom, &pkg_path, &dep_version, e2e_config.dep_mode, has_http_tests),
98            generated_header: false,
99        });
100
101        // Generate lib/e2e_elixir.ex — required so the mix project compiles.
102        files.push(GeneratedFile {
103            path: output_base.join("lib").join("e2e_elixir.ex"),
104            content: "defmodule E2eElixir do\n  @moduledoc false\nend\n".to_string(),
105            generated_header: false,
106        });
107
108        // Generate test_helper.exs.
109        files.push(GeneratedFile {
110            path: output_base.join("test").join("test_helper.exs"),
111            content: "ExUnit.start()\n".to_string(),
112            generated_header: false,
113        });
114
115        // Generate test files per category.
116        for group in groups {
117            let active: Vec<&Fixture> = group
118                .fixtures
119                .iter()
120                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
121                .collect();
122
123            if active.is_empty() {
124                continue;
125            }
126
127            let filename = format!("{}_test.exs", sanitize_filename(&group.category));
128            let field_resolver = FieldResolver::new(
129                &e2e_config.fields,
130                &e2e_config.fields_optional,
131                &e2e_config.result_fields,
132                &e2e_config.fields_array,
133            );
134            let content = render_test_file(
135                &group.category,
136                &active,
137                e2e_config,
138                &module_path,
139                &function_name,
140                result_var,
141                &e2e_config.call.args,
142                &field_resolver,
143                options_type.as_deref(),
144                options_default_fn.as_deref(),
145                enum_fields,
146                handle_struct_type.as_deref(),
147                handle_atom_list_fields,
148            );
149            files.push(GeneratedFile {
150                path: output_base.join("test").join(filename),
151                content,
152                generated_header: true,
153            });
154        }
155
156        Ok(files)
157    }
158
159    fn language_name(&self) -> &'static str {
160        "elixir"
161    }
162}
163
164fn render_mix_exs(
165    dep_atom: &str,
166    pkg_path: &str,
167    dep_version: &str,
168    dep_mode: crate::config::DependencyMode,
169    has_http_tests: bool,
170) -> String {
171    let mut out = String::new();
172    let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
173    let _ = writeln!(out, "  use Mix.Project");
174    let _ = writeln!(out);
175    let _ = writeln!(out, "  def project do");
176    let _ = writeln!(out, "    [");
177    let _ = writeln!(out, "      app: :e2e_elixir,");
178    let _ = writeln!(out, "      version: \"0.1.0\",");
179    let _ = writeln!(out, "      elixir: \"~> 1.14\",");
180    let _ = writeln!(out, "      deps: deps()");
181    let _ = writeln!(out, "    ]");
182    let _ = writeln!(out, "  end");
183    let _ = writeln!(out);
184    let _ = writeln!(out, "  defp deps do");
185    let _ = writeln!(out, "    [");
186    // Use a bare atom for the dep name (e.g., :html_to_markdown), not a quoted atom.
187    let dep_line = match dep_mode {
188        crate::config::DependencyMode::Registry => {
189            format!("      {{:{dep_atom}, \"{dep_version}\"}}")
190        }
191        crate::config::DependencyMode::Local => {
192            format!("      {{:{dep_atom}, path: \"{pkg_path}\"}}")
193        }
194    };
195    let _ = writeln!(out, "{dep_line}");
196    if has_http_tests {
197        let _ = writeln!(out, "      {{:req, \"~> 0.5\"}}");
198        let _ = writeln!(out, "      {{:jason, \"~> 1.4\"}}");
199    }
200    let _ = writeln!(out, "    ]");
201    let _ = writeln!(out, "  end");
202    let _ = writeln!(out, "end");
203    out
204}
205
206#[allow(clippy::too_many_arguments)]
207fn render_test_file(
208    category: &str,
209    fixtures: &[&Fixture],
210    e2e_config: &E2eConfig,
211    module_path: &str,
212    function_name: &str,
213    result_var: &str,
214    args: &[crate::config::ArgMapping],
215    field_resolver: &FieldResolver,
216    options_type: Option<&str>,
217    options_default_fn: Option<&str>,
218    enum_fields: &HashMap<String, String>,
219    handle_struct_type: Option<&str>,
220    handle_atom_list_fields: &std::collections::HashSet<String>,
221) -> String {
222    let mut out = String::new();
223    let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
224    let _ = writeln!(out, "# E2e tests for category: {category}");
225    let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
226    let _ = writeln!(out, "  use ExUnit.Case, async: true");
227
228    // Add client helper when there are HTTP fixtures in this group.
229    let has_http = fixtures.iter().any(|f| f.is_http_test());
230    if has_http {
231        let _ = writeln!(out);
232        let _ = writeln!(out, "  defp base_url do");
233        let _ = writeln!(
234            out,
235            "    System.get_env(\"TEST_SERVER_URL\") || \"http://localhost:8080\""
236        );
237        let _ = writeln!(out, "  end");
238        let _ = writeln!(out);
239        let _ = writeln!(out, "  defp client do");
240        let _ = writeln!(out, "    Req.new(base_url: base_url())");
241        let _ = writeln!(out, "  end");
242    }
243
244    let _ = writeln!(out);
245
246    for (i, fixture) in fixtures.iter().enumerate() {
247        if let Some(http) = &fixture.http {
248            render_http_test_case(&mut out, fixture, http);
249        } else {
250            render_test_case(
251                &mut out,
252                fixture,
253                e2e_config,
254                module_path,
255                function_name,
256                result_var,
257                args,
258                field_resolver,
259                options_type,
260                options_default_fn,
261                enum_fields,
262                handle_struct_type,
263                handle_atom_list_fields,
264            );
265        }
266        if i + 1 < fixtures.len() {
267            let _ = writeln!(out);
268        }
269    }
270
271    let _ = writeln!(out, "end");
272    out
273}
274
275// ---------------------------------------------------------------------------
276// HTTP test rendering
277// ---------------------------------------------------------------------------
278
279/// Render an ExUnit `describe` + `test` block for an HTTP server test fixture.
280fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
281    let test_name = sanitize_ident(&fixture.id);
282    let description = fixture.description.replace('"', "\\\"");
283    let method = http.request.method.to_uppercase();
284    let path = &http.request.path;
285
286    let _ = writeln!(out, "  describe \"{test_name}\" do");
287    let _ = writeln!(out, "    test \"{method} {path} - {description}\" do");
288
289    // Build request.
290    render_elixir_http_request(out, &http.request);
291
292    // Assert status.
293    let status = http.expected_response.status_code;
294    let _ = writeln!(out, "      assert response.status == {status}");
295
296    // Assert body.
297    render_elixir_body_assertions(out, &http.expected_response);
298
299    // Assert headers.
300    render_elixir_header_assertions(out, &http.expected_response);
301
302    let _ = writeln!(out, "    end");
303    let _ = writeln!(out, "  end");
304}
305
306/// Emit Req request lines inside an ExUnit test.
307fn render_elixir_http_request(out: &mut String, req: &HttpRequest) {
308    let method = req.method.to_lowercase();
309
310    let mut opts: Vec<String> = Vec::new();
311
312    if let Some(body) = &req.body {
313        let elixir_val = json_to_elixir(body);
314        opts.push(format!("json: {elixir_val}"));
315    }
316
317    if !req.headers.is_empty() {
318        let header_pairs: Vec<String> = req
319            .headers
320            .iter()
321            .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
322            .collect();
323        opts.push(format!("headers: [{}]", header_pairs.join(", ")));
324    }
325
326    if !req.cookies.is_empty() {
327        let cookie_str = req
328            .cookies
329            .iter()
330            .map(|(k, v)| format!("{}={}", k, v))
331            .collect::<Vec<_>>()
332            .join("; ");
333        opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
334    }
335
336    if !req.query_params.is_empty() {
337        let pairs: Vec<String> = req
338            .query_params
339            .iter()
340            .map(|(k, v)| {
341                let val_str = match v {
342                    serde_json::Value::String(s) => s.clone(),
343                    other => other.to_string(),
344                };
345                format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
346            })
347            .collect();
348        opts.push(format!("params: [{}]", pairs.join(", ")));
349    }
350
351    let path_lit = format!("\"{}\"", escape_elixir(&req.path));
352    if opts.is_empty() {
353        let _ = writeln!(out, "      {{:ok, response}} = Req.{method}(client(), url: {path_lit})");
354    } else {
355        let opts_str = opts.join(", ");
356        let _ = writeln!(
357            out,
358            "      {{:ok, response}} = Req.{method}(client(), url: {path_lit}, {opts_str})"
359        );
360    }
361}
362
363/// Emit body assertions for an HTTP expected response.
364fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
365    if let Some(body) = &expected.body {
366        let elixir_val = json_to_elixir(body);
367        let _ = writeln!(out, "      assert Jason.decode!(response.body) == {elixir_val}");
368    }
369    if let Some(partial) = &expected.body_partial {
370        if let Some(obj) = partial.as_object() {
371            let _ = writeln!(out, "      decoded_body = Jason.decode!(response.body)");
372            for (key, val) in obj {
373                let key_lit = format!("\"{}\"", escape_elixir(key));
374                let elixir_val = json_to_elixir(val);
375                let _ = writeln!(out, "      assert decoded_body[{key_lit}] == {elixir_val}");
376            }
377        }
378    }
379    if let Some(errors) = &expected.validation_errors {
380        for err in errors {
381            let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
382            let _ = writeln!(
383                out,
384                "      assert String.contains?(Jason.encode!(response.body), {msg_lit})"
385            );
386        }
387    }
388}
389
390/// Emit header assertions for an HTTP expected response.
391///
392/// Special tokens:
393/// - `"<<present>>"` — assert the header key exists
394/// - `"<<absent>>"` — assert the header key is absent
395/// - `"<<uuid>>"` — assert the header value matches a UUID regex
396fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
397    for (name, value) in &expected.headers {
398        let header_key = name.to_lowercase();
399        let key_lit = format!("\"{}\"", escape_elixir(&header_key));
400        // Req stores response headers as a list of {name, value} tuples.
401        let get_header_expr =
402            format!("Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: v end)");
403        match value.as_str() {
404            "<<present>>" => {
405                let _ = writeln!(out, "      assert {get_header_expr} != nil");
406            }
407            "<<absent>>" => {
408                let _ = writeln!(out, "      assert {get_header_expr} == nil");
409            }
410            "<<uuid>>" => {
411                let _ = writeln!(
412                    out,
413                    "      header_val_{} = {get_header_expr}",
414                    sanitize_ident(&header_key)
415                );
416                let _ = writeln!(
417                    out,
418                    "      assert Regex.match?(~r/^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/i, to_string(header_val_{}))",
419                    sanitize_ident(&header_key)
420                );
421            }
422            literal => {
423                let val_lit = format!("\"{}\"", escape_elixir(literal));
424                let _ = writeln!(out, "      assert {get_header_expr} == {val_lit}");
425            }
426        }
427    }
428}
429
430// ---------------------------------------------------------------------------
431// Function-call test rendering
432// ---------------------------------------------------------------------------
433
434#[allow(clippy::too_many_arguments)]
435fn render_test_case(
436    out: &mut String,
437    fixture: &Fixture,
438    e2e_config: &E2eConfig,
439    default_module_path: &str,
440    default_function_name: &str,
441    default_result_var: &str,
442    args: &[crate::config::ArgMapping],
443    field_resolver: &FieldResolver,
444    options_type: Option<&str>,
445    options_default_fn: Option<&str>,
446    enum_fields: &HashMap<String, String>,
447    handle_struct_type: Option<&str>,
448    handle_atom_list_fields: &std::collections::HashSet<String>,
449) {
450    let test_name = sanitize_ident(&fixture.id);
451    let description = fixture.description.replace('"', "\\\"");
452
453    // Resolve per-fixture call config (falls back to default if fixture.call is None).
454    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
455    let lang = "elixir";
456    let call_overrides = call_config.overrides.get(lang);
457
458    // Compute module_path and function_name from the resolved call config,
459    // applying Elixir-specific PascalCase conversion.
460    let (module_path, function_name, result_var) = if fixture.call.is_some() {
461        let raw_module = call_overrides
462            .and_then(|o| o.module.as_ref())
463            .cloned()
464            .unwrap_or_else(|| call_config.module.clone());
465        let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
466        {
467            raw_module.clone()
468        } else {
469            elixir_module_name(&raw_module)
470        };
471        let base_fn = call_overrides
472            .and_then(|o| o.function.as_ref())
473            .cloned()
474            .unwrap_or_else(|| call_config.function.clone());
475        let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
476            format!("{base_fn}_async")
477        } else {
478            base_fn
479        };
480        (resolved_module, resolved_fn, call_config.result_var.clone())
481    } else {
482        (
483            default_module_path.to_string(),
484            default_function_name.to_string(),
485            default_result_var.to_string(),
486        )
487    };
488
489    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
490
491    let (mut setup_lines, args_str) = build_args_and_setup(
492        &fixture.input,
493        args,
494        &module_path,
495        options_type,
496        options_default_fn,
497        enum_fields,
498        &fixture.id,
499        handle_struct_type,
500        handle_atom_list_fields,
501    );
502
503    // Build visitor if present — must happen before emitting setup_lines so the
504    // visitor setup line is included in the loop below.
505    let final_args = if let Some(visitor_spec) = &fixture.visitor {
506        let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
507        format!("{args_str}, {visitor_var}")
508    } else {
509        args_str
510    };
511
512    let _ = writeln!(out, "  describe \"{test_name}\" do");
513    let _ = writeln!(out, "    test \"{description}\" do");
514
515    for line in &setup_lines {
516        let _ = writeln!(out, "      {line}");
517    }
518
519    if expects_error {
520        let _ = writeln!(
521            out,
522            "      assert {{:error, _}} = {module_path}.{function_name}({final_args})"
523        );
524        let _ = writeln!(out, "    end");
525        let _ = writeln!(out, "  end");
526        return;
527    }
528
529    let _ = writeln!(
530        out,
531        "      {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
532    );
533
534    for assertion in &fixture.assertions {
535        render_assertion(out, assertion, &result_var, field_resolver, &module_path);
536    }
537
538    let _ = writeln!(out, "    end");
539    let _ = writeln!(out, "  end");
540}
541
542/// Build setup lines (e.g. handle creation) and the argument list for the function call.
543///
544/// Returns `(setup_lines, args_string)`.
545#[allow(clippy::too_many_arguments)]
546fn build_args_and_setup(
547    input: &serde_json::Value,
548    args: &[crate::config::ArgMapping],
549    module_path: &str,
550    options_type: Option<&str>,
551    options_default_fn: Option<&str>,
552    enum_fields: &HashMap<String, String>,
553    fixture_id: &str,
554    _handle_struct_type: Option<&str>,
555    _handle_atom_list_fields: &std::collections::HashSet<String>,
556) -> (Vec<String>, String) {
557    if args.is_empty() {
558        return (Vec::new(), json_to_elixir(input));
559    }
560
561    let mut setup_lines: Vec<String> = Vec::new();
562    let mut parts: Vec<String> = Vec::new();
563
564    for arg in args {
565        if arg.arg_type == "mock_url" {
566            setup_lines.push(format!(
567                "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
568                arg.name,
569            ));
570            parts.push(arg.name.clone());
571            continue;
572        }
573
574        if arg.arg_type == "handle" {
575            // Generate a create_{name} call using {:ok, name} = ... pattern.
576            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
577            // so that partial maps work: serde_json::from_str respects #[serde(default)].
578            let constructor_name = format!("create_{}", arg.name.to_snake_case());
579            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
580            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
581            let name = &arg.name;
582            if config_value.is_null()
583                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
584            {
585                setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
586            } else {
587                // Serialize the config map to a JSON string with Jason so that Rust can
588                // deserialize it with serde_json and apply field defaults for missing keys.
589                let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
590                let escaped = escape_elixir(&json_str);
591                setup_lines.push(format!("{name}_config = \"{escaped}\""));
592                setup_lines.push(format!(
593                    "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
594                ));
595            }
596            parts.push(arg.name.clone());
597            continue;
598        }
599
600        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
601        let val = input.get(field);
602        match val {
603            None | Some(serde_json::Value::Null) if arg.optional => {
604                // Optional arg with no fixture value: skip entirely.
605                continue;
606            }
607            None | Some(serde_json::Value::Null) => {
608                // Required arg with no fixture value: pass a language-appropriate default.
609                let default_val = match arg.arg_type.as_str() {
610                    "string" => "\"\"".to_string(),
611                    "int" | "integer" => "0".to_string(),
612                    "float" | "number" => "0.0".to_string(),
613                    "bool" | "boolean" => "false".to_string(),
614                    _ => "nil".to_string(),
615                };
616                parts.push(default_val);
617            }
618            Some(v) => {
619                // For json_object args with options_type, build a proper struct.
620                if arg.arg_type == "json_object" && !v.is_null() {
621                    if let (Some(_opts_type), Some(options_fn), Some(obj)) =
622                        (options_type, options_default_fn, v.as_object())
623                    {
624                        // Add setup line to initialize options from default function.
625                        let options_var = "options";
626                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
627
628                        // For each field in the options object, add a struct update line.
629                        for (k, vv) in obj.iter() {
630                            let snake_key = k.to_snake_case();
631                            let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
632                                if let Some(s) = vv.as_str() {
633                                    let snake_val = s.to_snake_case();
634                                    // Use atom for enum values, not string
635                                    format!(":{snake_val}")
636                                } else {
637                                    json_to_elixir(vv)
638                                }
639                            } else {
640                                json_to_elixir(vv)
641                            };
642                            setup_lines.push(format!(
643                                "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
644                            ));
645                        }
646
647                        // Push the variable name as the argument.
648                        parts.push(options_var.to_string());
649                        continue;
650                    }
651                }
652                parts.push(json_to_elixir(v));
653            }
654        }
655    }
656
657    (setup_lines, parts.join(", "))
658}
659
660/// Returns true if the field expression is a numeric/integer expression
661/// (e.g., a `length(...)` call) rather than a string.
662fn is_numeric_expr(field_expr: &str) -> bool {
663    field_expr.starts_with("length(")
664}
665
666fn render_assertion(
667    out: &mut String,
668    assertion: &Assertion,
669    result_var: &str,
670    field_resolver: &FieldResolver,
671    module_path: &str,
672) {
673    // Skip assertions on fields that don't exist on the result type.
674    if let Some(f) = &assertion.field {
675        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
676            let _ = writeln!(out, "      # skipped: field '{f}' not available on result type");
677            return;
678        }
679    }
680
681    let field_expr = match &assertion.field {
682        Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
683        _ => result_var.to_string(),
684    };
685
686    // Only wrap in String.trim/0 when the expression is actually a string.
687    // Numeric expressions (e.g., length(...)) must not be wrapped.
688    let is_numeric = is_numeric_expr(&field_expr);
689    let trimmed_field_expr = if is_numeric {
690        field_expr.clone()
691    } else {
692        format!("String.trim({field_expr})")
693    };
694
695    match assertion.assertion_type.as_str() {
696        "equals" => {
697            if let Some(expected) = &assertion.value {
698                let elixir_val = json_to_elixir(expected);
699                // Apply String.trim only for string comparisons, not numeric ones.
700                let is_string_expected = expected.is_string();
701                if is_string_expected && !is_numeric {
702                    let _ = writeln!(out, "      assert {trimmed_field_expr} == {elixir_val}");
703                } else {
704                    let _ = writeln!(out, "      assert {field_expr} == {elixir_val}");
705                }
706            }
707        }
708        "contains" => {
709            if let Some(expected) = &assertion.value {
710                let elixir_val = json_to_elixir(expected);
711                // Use to_string() to handle atoms (enums) as well as strings
712                let _ = writeln!(
713                    out,
714                    "      assert String.contains?(to_string({field_expr}), {elixir_val})"
715                );
716            }
717        }
718        "contains_all" => {
719            if let Some(values) = &assertion.values {
720                for val in values {
721                    let elixir_val = json_to_elixir(val);
722                    let _ = writeln!(
723                        out,
724                        "      assert String.contains?(to_string({field_expr}), {elixir_val})"
725                    );
726                }
727            }
728        }
729        "not_contains" => {
730            if let Some(expected) = &assertion.value {
731                let elixir_val = json_to_elixir(expected);
732                let _ = writeln!(
733                    out,
734                    "      refute String.contains?(to_string({field_expr}), {elixir_val})"
735                );
736            }
737        }
738        "not_empty" => {
739            let _ = writeln!(out, "      assert {field_expr} != \"\"");
740        }
741        "is_empty" => {
742            if is_numeric {
743                // length(...) == 0
744                let _ = writeln!(out, "      assert {field_expr} == 0");
745            } else {
746                // Handle nil (None) as empty
747                let _ = writeln!(out, "      assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
748            }
749        }
750        "contains_any" => {
751            if let Some(values) = &assertion.values {
752                let items: Vec<String> = values.iter().map(json_to_elixir).collect();
753                let list_str = items.join(", ");
754                let _ = writeln!(
755                    out,
756                    "      assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
757                );
758            }
759        }
760        "greater_than" => {
761            if let Some(val) = &assertion.value {
762                let elixir_val = json_to_elixir(val);
763                let _ = writeln!(out, "      assert {field_expr} > {elixir_val}");
764            }
765        }
766        "less_than" => {
767            if let Some(val) = &assertion.value {
768                let elixir_val = json_to_elixir(val);
769                let _ = writeln!(out, "      assert {field_expr} < {elixir_val}");
770            }
771        }
772        "greater_than_or_equal" => {
773            if let Some(val) = &assertion.value {
774                let elixir_val = json_to_elixir(val);
775                let _ = writeln!(out, "      assert {field_expr} >= {elixir_val}");
776            }
777        }
778        "less_than_or_equal" => {
779            if let Some(val) = &assertion.value {
780                let elixir_val = json_to_elixir(val);
781                let _ = writeln!(out, "      assert {field_expr} <= {elixir_val}");
782            }
783        }
784        "starts_with" => {
785            if let Some(expected) = &assertion.value {
786                let elixir_val = json_to_elixir(expected);
787                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
788            }
789        }
790        "ends_with" => {
791            if let Some(expected) = &assertion.value {
792                let elixir_val = json_to_elixir(expected);
793                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
794            }
795        }
796        "min_length" => {
797            if let Some(val) = &assertion.value {
798                if let Some(n) = val.as_u64() {
799                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
800                }
801            }
802        }
803        "max_length" => {
804            if let Some(val) = &assertion.value {
805                if let Some(n) = val.as_u64() {
806                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
807                }
808            }
809        }
810        "count_min" => {
811            if let Some(val) = &assertion.value {
812                if let Some(n) = val.as_u64() {
813                    let _ = writeln!(out, "      assert length({field_expr}) >= {n}");
814                }
815            }
816        }
817        "count_equals" => {
818            if let Some(val) = &assertion.value {
819                if let Some(n) = val.as_u64() {
820                    let _ = writeln!(out, "      assert length({field_expr}) == {n}");
821                }
822            }
823        }
824        "is_true" => {
825            let _ = writeln!(out, "      assert {field_expr} == true");
826        }
827        "is_false" => {
828            let _ = writeln!(out, "      assert {field_expr} == false");
829        }
830        "method_result" => {
831            if let Some(method_name) = &assertion.method {
832                let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
833                let check = assertion.check.as_deref().unwrap_or("is_true");
834                match check {
835                    "equals" => {
836                        if let Some(val) = &assertion.value {
837                            let elixir_val = json_to_elixir(val);
838                            let _ = writeln!(out, "      assert {call_expr} == {elixir_val}");
839                        }
840                    }
841                    "is_true" => {
842                        let _ = writeln!(out, "      assert {call_expr} == true");
843                    }
844                    "is_false" => {
845                        let _ = writeln!(out, "      assert {call_expr} == false");
846                    }
847                    "greater_than_or_equal" => {
848                        if let Some(val) = &assertion.value {
849                            let n = val.as_u64().unwrap_or(0);
850                            let _ = writeln!(out, "      assert {call_expr} >= {n}");
851                        }
852                    }
853                    "count_min" => {
854                        if let Some(val) = &assertion.value {
855                            let n = val.as_u64().unwrap_or(0);
856                            let _ = writeln!(out, "      assert length({call_expr}) >= {n}");
857                        }
858                    }
859                    "contains" => {
860                        if let Some(val) = &assertion.value {
861                            let elixir_val = json_to_elixir(val);
862                            let _ = writeln!(out, "      assert String.contains?({call_expr}, {elixir_val})");
863                        }
864                    }
865                    "is_error" => {
866                        let _ = writeln!(out, "      assert_raise RuntimeError, fn -> {call_expr} end");
867                    }
868                    other_check => {
869                        panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
870                    }
871                }
872            } else {
873                panic!("Elixir e2e generator: method_result assertion missing 'method' field");
874            }
875        }
876        "not_error" => {
877            // Already handled — the call would fail if it returned {:error, _}.
878        }
879        "error" => {
880            // Handled at the test level.
881        }
882        other => {
883            panic!("Elixir e2e generator: unsupported assertion type: {other}");
884        }
885    }
886}
887
888/// Build an Elixir call expression for a `method_result` assertion on a tree-sitter result.
889/// Maps method names to the appropriate `module_path` function calls.
890fn build_elixir_method_call(
891    result_var: &str,
892    method_name: &str,
893    args: Option<&serde_json::Value>,
894    module_path: &str,
895) -> String {
896    match method_name {
897        "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
898        "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
899        "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
900        "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
901        "contains_node_type" => {
902            let node_type = args
903                .and_then(|a| a.get("node_type"))
904                .and_then(|v| v.as_str())
905                .unwrap_or("");
906            format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
907        }
908        "find_nodes_by_type" => {
909            let node_type = args
910                .and_then(|a| a.get("node_type"))
911                .and_then(|v| v.as_str())
912                .unwrap_or("");
913            format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
914        }
915        "run_query" => {
916            let query_source = args
917                .and_then(|a| a.get("query_source"))
918                .and_then(|v| v.as_str())
919                .unwrap_or("");
920            let language = args
921                .and_then(|a| a.get("language"))
922                .and_then(|v| v.as_str())
923                .unwrap_or("");
924            format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
925        }
926        _ => format!("{module_path}.{method_name}({result_var})"),
927    }
928}
929
930/// Convert a category name to an Elixir module-safe PascalCase name.
931fn elixir_module_name(category: &str) -> String {
932    use heck::ToUpperCamelCase;
933    category.to_upper_camel_case()
934}
935
936/// Convert a `serde_json::Value` to an Elixir literal string.
937fn json_to_elixir(value: &serde_json::Value) -> String {
938    match value {
939        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
940        serde_json::Value::Bool(true) => "true".to_string(),
941        serde_json::Value::Bool(false) => "false".to_string(),
942        serde_json::Value::Number(n) => n.to_string(),
943        serde_json::Value::Null => "nil".to_string(),
944        serde_json::Value::Array(arr) => {
945            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
946            format!("[{}]", items.join(", "))
947        }
948        serde_json::Value::Object(map) => {
949            let entries: Vec<String> = map
950                .iter()
951                .map(|(k, v)| format!("\"{}\" => {}", k.to_snake_case(), json_to_elixir(v)))
952                .collect();
953            format!("%{{{}}}", entries.join(", "))
954        }
955    }
956}
957
958/// Build an Elixir visitor map and add setup line. Returns the visitor variable name.
959fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
960    use std::fmt::Write as FmtWrite;
961    let mut visitor_obj = String::new();
962    let _ = writeln!(visitor_obj, "%{{");
963    for (method_name, action) in &visitor_spec.callbacks {
964        emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
965    }
966    let _ = writeln!(visitor_obj, "    }}");
967
968    setup_lines.push(format!("visitor = {visitor_obj}"));
969    "visitor".to_string()
970}
971
972/// Emit an Elixir visitor method for a callback action.
973fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
974    use std::fmt::Write as FmtWrite;
975
976    // Elixir uses atom keys and handle_ prefix
977    let handle_method = format!("handle_{}", &method_name[6..]); // strip "visit_" prefix
978    let params = match method_name {
979        "visit_link" => "_ctx, _href, _text, _title",
980        "visit_image" => "_ctx, _src, _alt, _title",
981        "visit_heading" => "_ctx, _level, text, _id",
982        "visit_code_block" => "_ctx, _lang, _code",
983        "visit_code_inline"
984        | "visit_strong"
985        | "visit_emphasis"
986        | "visit_strikethrough"
987        | "visit_underline"
988        | "visit_subscript"
989        | "visit_superscript"
990        | "visit_mark"
991        | "visit_button"
992        | "visit_summary"
993        | "visit_figcaption"
994        | "visit_definition_term"
995        | "visit_definition_description" => "_ctx, _text",
996        "visit_text" => "_ctx, _text",
997        "visit_list_item" => "_ctx, _ordered, _marker, _text",
998        "visit_blockquote" => "_ctx, _content, _depth",
999        "visit_table_row" => "_ctx, _cells, _is_header",
1000        "visit_custom_element" => "_ctx, _tag_name, _html",
1001        "visit_form" => "_ctx, _action_url, _method",
1002        "visit_input" => "_ctx, _input_type, _name, _value",
1003        "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1004        "visit_details" => "_ctx, _is_open",
1005        _ => "_ctx",
1006    };
1007
1008    let _ = writeln!(out, "      :{handle_method} => fn({params}) ->");
1009    match action {
1010        CallbackAction::Skip => {
1011            let _ = writeln!(out, "        :skip");
1012        }
1013        CallbackAction::Continue => {
1014            let _ = writeln!(out, "        :continue");
1015        }
1016        CallbackAction::PreserveHtml => {
1017            let _ = writeln!(out, "        :preserve_html");
1018        }
1019        CallbackAction::Custom { output } => {
1020            let escaped = escape_elixir(output);
1021            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1022        }
1023        CallbackAction::CustomTemplate { template } => {
1024            // For template, use string interpolation in Elixir (but simplified without arg binding)
1025            let escaped = escape_elixir(template);
1026            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1027        }
1028    }
1029    let _ = writeln!(out, "      end,");
1030}