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