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