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        // Check if any fixture in any group is an HTTP test.
74        let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
75        let has_nif_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| !f.is_http_test()));
76
77        // Resolve package reference (path or version) for the NIF dependency.
78        let pkg_ref = e2e_config.resolve_package(lang);
79        let pkg_path = if has_nif_tests {
80            pkg_ref.as_ref().and_then(|p| p.path.as_deref()).unwrap_or("")
81        } else {
82            ""
83        };
84
85        // Generate mix.exs. The dep atom must match the binding's mix package
86        // name (e.g. `:spikard`), not a hardcoded value, otherwise consumer
87        // mix.exs files reference the wrong package.
88        let pkg_atom = alef_config.crate_config.name.replace('-', "_");
89        files.push(GeneratedFile {
90            path: output_base.join("mix.exs"),
91            content: render_mix_exs(&pkg_atom, pkg_path, e2e_config.dep_mode, has_http_tests, has_nif_tests),
92            generated_header: false,
93        });
94
95        // Generate lib/e2e_elixir.ex — required so the mix project compiles.
96        files.push(GeneratedFile {
97            path: output_base.join("lib").join("e2e_elixir.ex"),
98            content: "defmodule E2eElixir do\n  @moduledoc false\nend\n".to_string(),
99            generated_header: false,
100        });
101
102        // Generate test_helper.exs.
103        files.push(GeneratedFile {
104            path: output_base.join("test").join("test_helper.exs"),
105            content: render_test_helper(has_http_tests),
106            generated_header: false,
107        });
108
109        // Generate test files per category.
110        for group in groups {
111            let active: Vec<&Fixture> = group
112                .fixtures
113                .iter()
114                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
115                .collect();
116
117            if active.is_empty() {
118                continue;
119            }
120
121            let filename = format!("{}_test.exs", sanitize_filename(&group.category));
122            let field_resolver = FieldResolver::new(
123                &e2e_config.fields,
124                &e2e_config.fields_optional,
125                &e2e_config.result_fields,
126                &e2e_config.fields_array,
127            );
128            let content = render_test_file(
129                &group.category,
130                &active,
131                e2e_config,
132                &module_path,
133                &function_name,
134                result_var,
135                &e2e_config.call.args,
136                &field_resolver,
137                options_type.as_deref(),
138                options_default_fn.as_deref(),
139                enum_fields,
140                handle_struct_type.as_deref(),
141                handle_atom_list_fields,
142            );
143            files.push(GeneratedFile {
144                path: output_base.join("test").join(filename),
145                content,
146                generated_header: true,
147            });
148        }
149
150        Ok(files)
151    }
152
153    fn language_name(&self) -> &'static str {
154        "elixir"
155    }
156}
157
158fn render_test_helper(has_http_tests: bool) -> String {
159    if has_http_tests {
160        r#"ExUnit.start()
161
162# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
163mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
164fixtures_dir = Path.expand("../../../fixtures", __DIR__)
165
166if File.exists?(mock_server_bin) do
167  port = Port.open({:spawn_executable, mock_server_bin}, [
168    :binary,
169    :line,
170    args: [fixtures_dir]
171  ])
172  receive do
173    {^port, {:data, {:eol, "MOCK_SERVER_URL=" <> url}}} ->
174      System.put_env("MOCK_SERVER_URL", url)
175  after
176    30_000 ->
177      raise "mock-server startup timeout"
178  end
179end
180"#
181        .to_string()
182    } else {
183        "ExUnit.start()\n".to_string()
184    }
185}
186
187fn render_mix_exs(
188    pkg_name: &str,
189    pkg_path: &str,
190    dep_mode: crate::config::DependencyMode,
191    has_http_tests: bool,
192    has_nif_tests: bool,
193) -> String {
194    let mut out = String::new();
195    let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
196    let _ = writeln!(out, "  use Mix.Project");
197    let _ = writeln!(out);
198    let _ = writeln!(out, "  def project do");
199    let _ = writeln!(out, "    [");
200    let _ = writeln!(out, "      app: :e2e_elixir,");
201    let _ = writeln!(out, "      version: \"0.1.0\",");
202    let _ = writeln!(out, "      elixir: \"~> 1.14\",");
203    let _ = writeln!(out, "      deps: deps()");
204    let _ = writeln!(out, "    ]");
205    let _ = writeln!(out, "  end");
206    let _ = writeln!(out);
207    let _ = writeln!(out, "  defp deps do");
208    let _ = writeln!(out, "    [");
209
210    // Build the list of deps, then join with commas to avoid double-commas.
211    let mut deps: Vec<String> = Vec::new();
212
213    // Add the binding NIF dependency when there are non-HTTP tests.
214    if has_nif_tests && !pkg_path.is_empty() {
215        let pkg_atom = pkg_name;
216        let nif_dep = match dep_mode {
217            crate::config::DependencyMode::Local => {
218                format!("      {{:{pkg_atom}, path: \"{pkg_path}\"}}")
219            }
220            crate::config::DependencyMode::Registry => {
221                // Registry mode: pkg_path is repurposed as the version string.
222                format!("      {{:{pkg_atom}, \"{pkg_path}\"}}")
223            }
224        };
225        deps.push(nif_dep);
226        // rustler must be a direct dep in the consumer project for force_build to work.
227        deps.push(format!(
228            "      {{:rustler, \"{rustler}\", optional: true, runtime: false}}",
229            rustler = tv::hex::RUSTLER
230        ));
231    }
232
233    // Add Req + Jason for HTTP testing.
234    if has_http_tests {
235        deps.push(format!("      {{:req, \"{req}\"}}", req = tv::hex::REQ));
236        deps.push(format!("      {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
237    }
238
239    let _ = writeln!(out, "{}", deps.join(",\n"));
240    let _ = writeln!(out, "    ]");
241    let _ = writeln!(out, "  end");
242    let _ = writeln!(out, "end");
243    out
244}
245
246#[allow(clippy::too_many_arguments)]
247fn render_test_file(
248    category: &str,
249    fixtures: &[&Fixture],
250    e2e_config: &E2eConfig,
251    module_path: &str,
252    function_name: &str,
253    result_var: &str,
254    args: &[crate::config::ArgMapping],
255    field_resolver: &FieldResolver,
256    options_type: Option<&str>,
257    options_default_fn: Option<&str>,
258    enum_fields: &HashMap<String, String>,
259    handle_struct_type: Option<&str>,
260    handle_atom_list_fields: &std::collections::HashSet<String>,
261) -> String {
262    let mut out = String::new();
263    out.push_str(&hash::header(CommentStyle::Hash));
264    let _ = writeln!(out, "# E2e tests for category: {category}");
265    let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
266
267    // Add client helper when there are HTTP fixtures in this group.
268    let has_http = fixtures.iter().any(|f| f.is_http_test());
269
270    // Use async: false for NIF tests — concurrent Tokio runtimes created by DirtyCpu NIFs
271    // on ARM64 macOS cause SIGBUS when tests run in parallel. HTTP-only tests can stay async.
272    let async_flag = if has_http { "true" } else { "false" };
273    let _ = writeln!(out, "  use ExUnit.Case, async: {async_flag}");
274
275    if has_http {
276        let _ = writeln!(out);
277        let _ = writeln!(out, "  defp mock_server_url do");
278        let _ = writeln!(
279            out,
280            "    System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
281        );
282        let _ = writeln!(out, "  end");
283    }
284
285    let _ = writeln!(out);
286
287    for (i, fixture) in fixtures.iter().enumerate() {
288        if let Some(http) = &fixture.http {
289            render_http_test_case(&mut out, fixture, http);
290        } else {
291            render_test_case(
292                &mut out,
293                fixture,
294                e2e_config,
295                module_path,
296                function_name,
297                result_var,
298                args,
299                field_resolver,
300                options_type,
301                options_default_fn,
302                enum_fields,
303                handle_struct_type,
304                handle_atom_list_fields,
305            );
306        }
307        if i + 1 < fixtures.len() {
308            let _ = writeln!(out);
309        }
310    }
311
312    let _ = writeln!(out, "end");
313    out
314}
315
316// ---------------------------------------------------------------------------
317// HTTP test rendering
318// ---------------------------------------------------------------------------
319
320/// HTTP methods that Finch (Req's underlying HTTP client) does not support.
321/// Tests using these methods are emitted with `@tag :skip` so they don't fail.
322const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
323
324/// Render an ExUnit `describe` + `test` block for an HTTP server test fixture.
325fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
326    let test_name = sanitize_ident(&fixture.id);
327    let description = fixture.description.replace('"', "\\\"");
328    let method = http.request.method.to_uppercase();
329    let path = &http.request.path;
330    let fixture_id = &fixture.id;
331
332    let _ = writeln!(out, "  describe \"{test_name}\" do");
333
334    // Skip tests for HTTP methods that Finch does not support.
335    if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
336        let _ = writeln!(out, "    @tag :skip");
337    }
338
339    let _ = writeln!(out, "    test \"{method} {path} - {description}\" do");
340
341    // Build request targeting the mock server.
342    render_elixir_http_request(out, &http.request, fixture_id, http.expected_response.status_code);
343
344    // Assert status.
345    let status = http.expected_response.status_code;
346    let _ = writeln!(out, "      assert response.status == {status}");
347
348    // Assert body.
349    render_elixir_body_assertions(out, &http.expected_response);
350
351    // Assert headers.
352    render_elixir_header_assertions(out, &http.expected_response);
353
354    let _ = writeln!(out, "    end");
355    let _ = writeln!(out, "  end");
356}
357
358/// HTTP methods that Req exposes as convenience functions.
359/// All others must be called via `Req.request(method: :METHOD, ...)`.
360const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
361
362/// Emit Req request lines inside an ExUnit test.
363fn render_elixir_http_request(out: &mut String, req: &HttpRequest, fixture_id: &str, expected_status: u16) {
364    let method = req.method.to_lowercase();
365
366    let mut opts: Vec<String> = Vec::new();
367
368    if let Some(body) = &req.body {
369        let elixir_val = json_to_elixir(body);
370        opts.push(format!("json: {elixir_val}"));
371    }
372
373    if !req.headers.is_empty() {
374        let header_pairs: Vec<String> = req
375            .headers
376            .iter()
377            .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
378            .collect();
379        opts.push(format!("headers: [{}]", header_pairs.join(", ")));
380    }
381
382    if !req.cookies.is_empty() {
383        let cookie_str = req
384            .cookies
385            .iter()
386            .map(|(k, v)| format!("{}={}", k, v))
387            .collect::<Vec<_>>()
388            .join("; ");
389        opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
390    }
391
392    if !req.query_params.is_empty() {
393        let pairs: Vec<String> = req
394            .query_params
395            .iter()
396            .map(|(k, v)| {
397                let val_str = match v {
398                    serde_json::Value::String(s) => s.clone(),
399                    other => other.to_string(),
400                };
401                format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
402            })
403            .collect();
404        opts.push(format!("params: [{}]", pairs.join(", ")));
405    }
406
407    // When the expected response is a redirect (3xx), disable automatic redirect
408    // following so the test can assert the redirect status and Location header.
409    if (300..400).contains(&expected_status) {
410        opts.push("redirect: false".to_string());
411    }
412
413    // Use the mock server's /fixtures/<id> endpoint.
414    let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{}\"", escape_elixir(fixture_id));
415
416    // Req only exposes convenience functions for common HTTP verbs.
417    // Less common methods (OPTIONS, CONNECT, TRACE, etc.) must use Req.request/1.
418    if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
419        if opts.is_empty() {
420            let _ = writeln!(out, "      {{:ok, response}} = Req.{method}(url: {url_expr})");
421        } else {
422            let opts_str = opts.join(", ");
423            let _ = writeln!(
424                out,
425                "      {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
426            );
427        }
428    } else {
429        // Fall back to Req.request/1 for non-standard methods.
430        opts.insert(0, format!("method: :{method}"));
431        opts.insert(1, format!("url: {url_expr}"));
432        let opts_str = opts.join(", ");
433        let _ = writeln!(out, "      {{:ok, response}} = Req.request({opts_str})");
434    }
435}
436
437/// Emit body assertions for an HTTP expected response.
438fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
439    if let Some(body) = &expected.body {
440        let elixir_val = json_to_elixir(body);
441        // Req auto-decodes `application/json` bodies, but non-JSON content types
442        // (e.g. `application/grpc`, `text/plain`) are returned as raw binaries.
443        // Guard with `if is_binary` so we can handle both cases uniformly.
444        match body {
445            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
446                // Expected body is a JSON object/array — decode when Req returns a binary.
447                let _ = writeln!(
448                    out,
449                    "      body_decoded = if is_binary(response.body), do: Jason.decode!(response.body), else: response.body"
450                );
451                let _ = writeln!(out, "      assert body_decoded == {elixir_val}");
452            }
453            _ => {
454                // String/number/bool: compare against response.body directly.
455                let _ = writeln!(out, "      assert response.body == {elixir_val}");
456            }
457        }
458    }
459    if let Some(partial) = &expected.body_partial {
460        if let Some(obj) = partial.as_object() {
461            // Req auto-decodes JSON bodies; decode when Req returns a binary.
462            let _ = writeln!(
463                out,
464                "      decoded_body = if is_binary(response.body), do: Jason.decode!(response.body), else: response.body"
465            );
466            for (key, val) in obj {
467                let key_lit = format!("\"{}\"", escape_elixir(key));
468                let elixir_val = json_to_elixir(val);
469                let _ = writeln!(out, "      assert decoded_body[{key_lit}] == {elixir_val}");
470            }
471        }
472    }
473    if let Some(errors) = &expected.validation_errors {
474        for err in errors {
475            let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
476            let _ = writeln!(
477                out,
478                "      assert String.contains?(Jason.encode!(response.body), {msg_lit})"
479            );
480        }
481    }
482}
483
484/// Emit header assertions for an HTTP expected response.
485///
486/// Special tokens:
487/// - `"<<present>>"` — assert the header key exists
488/// - `"<<absent>>"` — assert the header key is absent
489/// - `"<<uuid>>"` — assert the header value matches a UUID regex
490fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
491    for (name, value) in &expected.headers {
492        let header_key = name.to_lowercase();
493        // Skip headers Req's response pipeline transforms or strips:
494        // - content-encoding: Req auto-decompresses gzip/brotli responses and removes the header.
495        // - connection: hop-by-hop header stripped when reading responses.
496        if header_key == "content-encoding" || header_key == "connection" {
497            continue;
498        }
499        let key_lit = format!("\"{}\"", escape_elixir(&header_key));
500        // Req (via Mint) stores header values as lists; extract the first value.
501        let get_header_expr = format!(
502            "Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
503        );
504        match value.as_str() {
505            "<<present>>" => {
506                let _ = writeln!(out, "      assert {get_header_expr} != nil");
507            }
508            "<<absent>>" => {
509                let _ = writeln!(out, "      assert {get_header_expr} == nil");
510            }
511            "<<uuid>>" => {
512                let _ = writeln!(
513                    out,
514                    "      header_val_{} = {get_header_expr}",
515                    sanitize_ident(&header_key)
516                );
517                let _ = writeln!(
518                    out,
519                    "      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_{}))",
520                    sanitize_ident(&header_key)
521                );
522            }
523            literal => {
524                let val_lit = format!("\"{}\"", escape_elixir(literal));
525                let _ = writeln!(out, "      assert {get_header_expr} == {val_lit}");
526            }
527        }
528    }
529}
530
531// ---------------------------------------------------------------------------
532// Function-call test rendering
533// ---------------------------------------------------------------------------
534
535#[allow(clippy::too_many_arguments)]
536fn render_test_case(
537    out: &mut String,
538    fixture: &Fixture,
539    e2e_config: &E2eConfig,
540    default_module_path: &str,
541    default_function_name: &str,
542    default_result_var: &str,
543    args: &[crate::config::ArgMapping],
544    field_resolver: &FieldResolver,
545    options_type: Option<&str>,
546    options_default_fn: Option<&str>,
547    enum_fields: &HashMap<String, String>,
548    handle_struct_type: Option<&str>,
549    handle_atom_list_fields: &std::collections::HashSet<String>,
550) {
551    let test_name = sanitize_ident(&fixture.id);
552    let description = fixture.description.replace('"', "\\\"");
553
554    // Non-HTTP non-mock_response fixtures (e.g. AsyncAPI, WebSocket, OpenRPC
555    // protocol-only fixtures) cannot be tested via the configured `[e2e.call]`
556    // function when the binding does not expose it. Emit a documented `@tag :skip`
557    // test so the suite stays compilable. HTTP fixtures dispatch via render_http_test_case
558    // and never reach here.
559    if fixture.mock_response.is_none() {
560        let _ = writeln!(out, "  describe \"{test_name}\" do");
561        let _ = writeln!(out, "    @tag :skip");
562        let _ = writeln!(out, "    test \"{description}\" do");
563        let _ = writeln!(
564            out,
565            "      # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
566        );
567        let _ = writeln!(out, "      :ok");
568        let _ = writeln!(out, "    end");
569        let _ = writeln!(out, "  end");
570        return;
571    }
572
573    // Resolve per-fixture call config (falls back to default if fixture.call is None).
574    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
575    let lang = "elixir";
576    let call_overrides = call_config.overrides.get(lang);
577
578    // Compute module_path and function_name from the resolved call config,
579    // applying Elixir-specific PascalCase conversion.
580    let (module_path, function_name, result_var) = if fixture.call.is_some() {
581        let raw_module = call_overrides
582            .and_then(|o| o.module.as_ref())
583            .cloned()
584            .unwrap_or_else(|| call_config.module.clone());
585        let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
586        {
587            raw_module.clone()
588        } else {
589            elixir_module_name(&raw_module)
590        };
591        let base_fn = call_overrides
592            .and_then(|o| o.function.as_ref())
593            .cloned()
594            .unwrap_or_else(|| call_config.function.clone());
595        let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
596            format!("{base_fn}_async")
597        } else {
598            base_fn
599        };
600        (resolved_module, resolved_fn, call_config.result_var.clone())
601    } else {
602        (
603            default_module_path.to_string(),
604            default_function_name.to_string(),
605            default_result_var.to_string(),
606        )
607    };
608
609    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
610
611    // When the fixture uses a named call, use the args and options from that call's config.
612    let (
613        effective_args,
614        effective_options_type,
615        effective_options_default_fn,
616        effective_enum_fields,
617        effective_handle_struct_type,
618        effective_handle_atom_list_fields,
619    );
620    let empty_enum_fields_local: HashMap<String, String>;
621    let empty_atom_fields_local: std::collections::HashSet<String>;
622    let (
623        resolved_args,
624        resolved_options_type,
625        resolved_options_default_fn,
626        resolved_enum_fields_ref,
627        resolved_handle_struct_type,
628        resolved_handle_atom_list_fields_ref,
629    ) = if fixture.call.is_some() {
630        let co = call_config.overrides.get(lang);
631        effective_args = call_config.args.as_slice();
632        effective_options_type = co.and_then(|o| o.options_type.as_deref());
633        effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
634        empty_enum_fields_local = HashMap::new();
635        effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
636        effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
637        empty_atom_fields_local = std::collections::HashSet::new();
638        effective_handle_atom_list_fields = co
639            .map(|o| &o.handle_atom_list_fields)
640            .unwrap_or(&empty_atom_fields_local);
641        (
642            effective_args,
643            effective_options_type,
644            effective_options_default_fn,
645            effective_enum_fields,
646            effective_handle_struct_type,
647            effective_handle_atom_list_fields,
648        )
649    } else {
650        (
651            args as &[_],
652            options_type,
653            options_default_fn,
654            enum_fields,
655            handle_struct_type,
656            handle_atom_list_fields,
657        )
658    };
659
660    let (mut setup_lines, args_str) = build_args_and_setup(
661        &fixture.input,
662        resolved_args,
663        &module_path,
664        resolved_options_type,
665        resolved_options_default_fn,
666        resolved_enum_fields_ref,
667        &fixture.id,
668        resolved_handle_struct_type,
669        resolved_handle_atom_list_fields_ref,
670    );
671
672    // Build visitor if present — must happen before emitting setup_lines so the
673    // visitor setup line is included in the loop below.
674    let final_args = if let Some(visitor_spec) = &fixture.visitor {
675        let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
676        format!("{args_str}, {visitor_var}")
677    } else {
678        args_str
679    };
680
681    let _ = writeln!(out, "  describe \"{test_name}\" do");
682    let _ = writeln!(out, "    test \"{description}\" do");
683
684    for line in &setup_lines {
685        let _ = writeln!(out, "      {line}");
686    }
687
688    let returns_result = call_config.returns_result;
689
690    if expects_error {
691        if returns_result {
692            let _ = writeln!(
693                out,
694                "      assert {{:error, _}} = {module_path}.{function_name}({final_args})"
695            );
696        } else {
697            // Non-Result function — just call and discard; error detection not meaningful.
698            let _ = writeln!(out, "      _result = {module_path}.{function_name}({final_args})");
699        }
700        let _ = writeln!(out, "    end");
701        let _ = writeln!(out, "  end");
702        return;
703    }
704
705    if returns_result {
706        let _ = writeln!(
707            out,
708            "      {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
709        );
710    } else {
711        // Non-Result function returns value directly (e.g., bool, String).
712        let _ = writeln!(out, "      {result_var} = {module_path}.{function_name}({final_args})");
713    }
714
715    for assertion in &fixture.assertions {
716        render_assertion(out, assertion, &result_var, field_resolver, &module_path);
717    }
718
719    let _ = writeln!(out, "    end");
720    let _ = writeln!(out, "  end");
721}
722
723/// Build setup lines (e.g. handle creation) and the argument list for the function call.
724///
725/// Returns `(setup_lines, args_string)`.
726#[allow(clippy::too_many_arguments)]
727fn build_args_and_setup(
728    input: &serde_json::Value,
729    args: &[crate::config::ArgMapping],
730    module_path: &str,
731    options_type: Option<&str>,
732    options_default_fn: Option<&str>,
733    enum_fields: &HashMap<String, String>,
734    fixture_id: &str,
735    _handle_struct_type: Option<&str>,
736    _handle_atom_list_fields: &std::collections::HashSet<String>,
737) -> (Vec<String>, String) {
738    if args.is_empty() {
739        return (Vec::new(), json_to_elixir(input));
740    }
741
742    let mut setup_lines: Vec<String> = Vec::new();
743    let mut parts: Vec<String> = Vec::new();
744
745    for arg in args {
746        if arg.arg_type == "mock_url" {
747            setup_lines.push(format!(
748                "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
749                arg.name,
750            ));
751            parts.push(arg.name.clone());
752            continue;
753        }
754
755        if arg.arg_type == "handle" {
756            // Generate a create_{name} call using {:ok, name} = ... pattern.
757            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
758            // so that partial maps work: serde_json::from_str respects #[serde(default)].
759            let constructor_name = format!("create_{}", arg.name.to_snake_case());
760            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
761            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
762            let name = &arg.name;
763            if config_value.is_null()
764                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
765            {
766                setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
767            } else {
768                // Serialize the config map to a JSON string with Jason so that Rust can
769                // deserialize it with serde_json and apply field defaults for missing keys.
770                let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
771                let escaped = escape_elixir(&json_str);
772                setup_lines.push(format!("{name}_config = \"{escaped}\""));
773                setup_lines.push(format!(
774                    "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
775                ));
776            }
777            parts.push(arg.name.clone());
778            continue;
779        }
780
781        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
782        let val = input.get(field);
783        match val {
784            None | Some(serde_json::Value::Null) if arg.optional => {
785                // Elixir functions have fixed positional arity — pass nil for optional args
786                // rather than skipping them, so the call site has the correct arity.
787                parts.push("nil".to_string());
788                continue;
789            }
790            None | Some(serde_json::Value::Null) => {
791                // Required arg with no fixture value: pass a language-appropriate default.
792                let default_val = match arg.arg_type.as_str() {
793                    "string" => "\"\"".to_string(),
794                    "int" | "integer" => "0".to_string(),
795                    "float" | "number" => "0.0".to_string(),
796                    "bool" | "boolean" => "false".to_string(),
797                    _ => "nil".to_string(),
798                };
799                parts.push(default_val);
800            }
801            Some(v) => {
802                // For file_path args, prepend the path to the test_documents directory
803                // relative to the e2e/elixir/ directory where `mix test` runs.
804                if arg.arg_type == "file_path" {
805                    if let Some(path_str) = v.as_str() {
806                        let full_path = format!("../../test_documents/{path_str}");
807                        parts.push(format!("\"{}\"", escape_elixir(&full_path)));
808                        continue;
809                    }
810                }
811                // For bytes args, use File.read! for file paths and Base.decode64! for base64.
812                // Inline text (starts with '<', '{', '[' or contains spaces) is used as-is (UTF-8 binary).
813                if arg.arg_type == "bytes" {
814                    if let Some(raw) = v.as_str() {
815                        let var_name = &arg.name;
816                        if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
817                            // Inline text — use as a binary string.
818                            parts.push(format!("\"{}\"", escape_elixir(raw)));
819                        } else {
820                            let first = raw.chars().next().unwrap_or('\0');
821                            let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
822                                && raw
823                                    .find('/')
824                                    .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
825                            if is_file_path {
826                                // Looks like "dir/file.ext" — read from test_documents.
827                                let full_path = format!("../../test_documents/{raw}");
828                                let escaped = escape_elixir(&full_path);
829                                setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
830                                parts.push(var_name.to_string());
831                            } else {
832                                // Treat as base64-encoded binary.
833                                setup_lines.push(format!(
834                                    "{var_name} = Base.decode64!(\"{}\", padding: false)",
835                                    escape_elixir(raw)
836                                ));
837                                parts.push(var_name.to_string());
838                            }
839                        }
840                        continue;
841                    }
842                }
843                // For json_object args with options_type+options_via, build a proper struct.
844                if arg.arg_type == "json_object" && !v.is_null() {
845                    if let (Some(_opts_type), Some(options_fn), Some(obj)) =
846                        (options_type, options_default_fn, v.as_object())
847                    {
848                        // Add setup line to initialize options from default function.
849                        let options_var = "options";
850                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
851
852                        // For each field in the options object, add a struct update line.
853                        for (k, vv) in obj.iter() {
854                            let snake_key = k.to_snake_case();
855                            let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
856                                if let Some(s) = vv.as_str() {
857                                    let snake_val = s.to_snake_case();
858                                    // Use atom for enum values, not string
859                                    format!(":{snake_val}")
860                                } else {
861                                    json_to_elixir(vv)
862                                }
863                            } else {
864                                json_to_elixir(vv)
865                            };
866                            setup_lines.push(format!(
867                                "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
868                            ));
869                        }
870
871                        // Push the variable name as the argument.
872                        parts.push(options_var.to_string());
873                        continue;
874                    }
875                    // When there's no options_type+options_via, the Elixir NIF expects a JSON
876                    // string (Option<String> decoded by serde_json) rather than an Elixir map.
877                    // Serialize the JSON value to a string literal here.
878                    if !v.is_null() {
879                        let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
880                        let escaped = escape_elixir(&json_str);
881                        parts.push(format!("\"{escaped}\""));
882                        continue;
883                    }
884                }
885                parts.push(json_to_elixir(v));
886            }
887        }
888    }
889
890    (setup_lines, parts.join(", "))
891}
892
893/// Returns true if the field expression is a numeric/integer expression
894/// (e.g., a `length(...)` call) rather than a string.
895fn is_numeric_expr(field_expr: &str) -> bool {
896    field_expr.starts_with("length(")
897}
898
899fn render_assertion(
900    out: &mut String,
901    assertion: &Assertion,
902    result_var: &str,
903    field_resolver: &FieldResolver,
904    module_path: &str,
905) {
906    // Handle synthetic / derived fields before the is_valid_for_result check
907    // so they are never treated as struct field accesses on the result.
908    if let Some(f) = &assertion.field {
909        match f.as_str() {
910            "chunks_have_content" => {
911                let pred =
912                    format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
913                match assertion.assertion_type.as_str() {
914                    "is_true" => {
915                        let _ = writeln!(out, "      assert {pred}");
916                    }
917                    "is_false" => {
918                        let _ = writeln!(out, "      refute {pred}");
919                    }
920                    _ => {
921                        let _ = writeln!(
922                            out,
923                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
924                        );
925                    }
926                }
927                return;
928            }
929            "chunks_have_embeddings" => {
930                let pred = format!(
931                    "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
932                );
933                match assertion.assertion_type.as_str() {
934                    "is_true" => {
935                        let _ = writeln!(out, "      assert {pred}");
936                    }
937                    "is_false" => {
938                        let _ = writeln!(out, "      refute {pred}");
939                    }
940                    _ => {
941                        let _ = writeln!(
942                            out,
943                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
944                        );
945                    }
946                }
947                return;
948            }
949            // ---- EmbedResponse virtual fields ----
950            // embed_texts returns [[float]] in Elixir — no wrapper struct.
951            // result_var is the embedding matrix; use it directly.
952            "embeddings" => {
953                match assertion.assertion_type.as_str() {
954                    "count_equals" => {
955                        if let Some(val) = &assertion.value {
956                            let ex_val = json_to_elixir(val);
957                            let _ = writeln!(out, "      assert length({result_var}) == {ex_val}");
958                        }
959                    }
960                    "count_min" => {
961                        if let Some(val) = &assertion.value {
962                            let ex_val = json_to_elixir(val);
963                            let _ = writeln!(out, "      assert length({result_var}) >= {ex_val}");
964                        }
965                    }
966                    "not_empty" => {
967                        let _ = writeln!(out, "      assert {result_var} != []");
968                    }
969                    "is_empty" => {
970                        let _ = writeln!(out, "      assert {result_var} == []");
971                    }
972                    _ => {
973                        let _ = writeln!(
974                            out,
975                            "      # skipped: unsupported assertion type on synthetic field 'embeddings'"
976                        );
977                    }
978                }
979                return;
980            }
981            "embedding_dimensions" => {
982                let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
983                match assertion.assertion_type.as_str() {
984                    "equals" => {
985                        if let Some(val) = &assertion.value {
986                            let ex_val = json_to_elixir(val);
987                            let _ = writeln!(out, "      assert {expr} == {ex_val}");
988                        }
989                    }
990                    "greater_than" => {
991                        if let Some(val) = &assertion.value {
992                            let ex_val = json_to_elixir(val);
993                            let _ = writeln!(out, "      assert {expr} > {ex_val}");
994                        }
995                    }
996                    _ => {
997                        let _ = writeln!(
998                            out,
999                            "      # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1000                        );
1001                    }
1002                }
1003                return;
1004            }
1005            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1006                let pred = match f.as_str() {
1007                    "embeddings_valid" => {
1008                        format!("Enum.all?({result_var}, fn e -> e != [] end)")
1009                    }
1010                    "embeddings_finite" => {
1011                        format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1012                    }
1013                    "embeddings_non_zero" => {
1014                        format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1015                    }
1016                    "embeddings_normalized" => {
1017                        format!(
1018                            "Enum.all?({result_var}, fn e -> n = Enum.reduce(e, 0.0, fn v, acc -> acc + v * v end); abs(n - 1.0) < 1.0e-3 end)"
1019                        )
1020                    }
1021                    _ => unreachable!(),
1022                };
1023                match assertion.assertion_type.as_str() {
1024                    "is_true" => {
1025                        let _ = writeln!(out, "      assert {pred}");
1026                    }
1027                    "is_false" => {
1028                        let _ = writeln!(out, "      refute {pred}");
1029                    }
1030                    _ => {
1031                        let _ = writeln!(
1032                            out,
1033                            "      # skipped: unsupported assertion type on synthetic field '{f}'"
1034                        );
1035                    }
1036                }
1037                return;
1038            }
1039            // ---- keywords / keywords_count ----
1040            // Elixir ExtractionResult does not expose extracted_keywords; skip.
1041            "keywords" | "keywords_count" => {
1042                let _ = writeln!(
1043                    out,
1044                    "      # skipped: field '{f}' not available on Elixir ExtractionResult"
1045                );
1046                return;
1047            }
1048            _ => {}
1049        }
1050    }
1051
1052    // Skip assertions on fields that don't exist on the result type.
1053    if let Some(f) = &assertion.field {
1054        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1055            let _ = writeln!(out, "      # skipped: field '{f}' not available on result type");
1056            return;
1057        }
1058    }
1059
1060    let field_expr = match &assertion.field {
1061        Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1062        _ => result_var.to_string(),
1063    };
1064
1065    // Only wrap in String.trim/0 when the expression is actually a string.
1066    // Numeric expressions (e.g., length(...)) must not be wrapped.
1067    let is_numeric = is_numeric_expr(&field_expr);
1068    let trimmed_field_expr = if is_numeric {
1069        field_expr.clone()
1070    } else {
1071        format!("String.trim({field_expr})")
1072    };
1073
1074    match assertion.assertion_type.as_str() {
1075        "equals" => {
1076            if let Some(expected) = &assertion.value {
1077                let elixir_val = json_to_elixir(expected);
1078                // Apply String.trim only for string comparisons, not numeric ones.
1079                let is_string_expected = expected.is_string();
1080                if is_string_expected && !is_numeric {
1081                    let _ = writeln!(out, "      assert {trimmed_field_expr} == {elixir_val}");
1082                } else {
1083                    let _ = writeln!(out, "      assert {field_expr} == {elixir_val}");
1084                }
1085            }
1086        }
1087        "contains" => {
1088            if let Some(expected) = &assertion.value {
1089                let elixir_val = json_to_elixir(expected);
1090                // Use to_string() to handle atoms (enums) as well as strings
1091                let _ = writeln!(
1092                    out,
1093                    "      assert String.contains?(to_string({field_expr}), {elixir_val})"
1094                );
1095            }
1096        }
1097        "contains_all" => {
1098            if let Some(values) = &assertion.values {
1099                for val in values {
1100                    let elixir_val = json_to_elixir(val);
1101                    let _ = writeln!(
1102                        out,
1103                        "      assert String.contains?(to_string({field_expr}), {elixir_val})"
1104                    );
1105                }
1106            }
1107        }
1108        "not_contains" => {
1109            if let Some(expected) = &assertion.value {
1110                let elixir_val = json_to_elixir(expected);
1111                let _ = writeln!(
1112                    out,
1113                    "      refute String.contains?(to_string({field_expr}), {elixir_val})"
1114                );
1115            }
1116        }
1117        "not_empty" => {
1118            let _ = writeln!(out, "      assert {field_expr} != \"\"");
1119        }
1120        "is_empty" => {
1121            if is_numeric {
1122                // length(...) == 0
1123                let _ = writeln!(out, "      assert {field_expr} == 0");
1124            } else {
1125                // Handle nil (None) as empty
1126                let _ = writeln!(out, "      assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1127            }
1128        }
1129        "contains_any" => {
1130            if let Some(values) = &assertion.values {
1131                let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1132                let list_str = items.join(", ");
1133                let _ = writeln!(
1134                    out,
1135                    "      assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1136                );
1137            }
1138        }
1139        "greater_than" => {
1140            if let Some(val) = &assertion.value {
1141                let elixir_val = json_to_elixir(val);
1142                let _ = writeln!(out, "      assert {field_expr} > {elixir_val}");
1143            }
1144        }
1145        "less_than" => {
1146            if let Some(val) = &assertion.value {
1147                let elixir_val = json_to_elixir(val);
1148                let _ = writeln!(out, "      assert {field_expr} < {elixir_val}");
1149            }
1150        }
1151        "greater_than_or_equal" => {
1152            if let Some(val) = &assertion.value {
1153                let elixir_val = json_to_elixir(val);
1154                let _ = writeln!(out, "      assert {field_expr} >= {elixir_val}");
1155            }
1156        }
1157        "less_than_or_equal" => {
1158            if let Some(val) = &assertion.value {
1159                let elixir_val = json_to_elixir(val);
1160                let _ = writeln!(out, "      assert {field_expr} <= {elixir_val}");
1161            }
1162        }
1163        "starts_with" => {
1164            if let Some(expected) = &assertion.value {
1165                let elixir_val = json_to_elixir(expected);
1166                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
1167            }
1168        }
1169        "ends_with" => {
1170            if let Some(expected) = &assertion.value {
1171                let elixir_val = json_to_elixir(expected);
1172                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
1173            }
1174        }
1175        "min_length" => {
1176            if let Some(val) = &assertion.value {
1177                if let Some(n) = val.as_u64() {
1178                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
1179                }
1180            }
1181        }
1182        "max_length" => {
1183            if let Some(val) = &assertion.value {
1184                if let Some(n) = val.as_u64() {
1185                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
1186                }
1187            }
1188        }
1189        "count_min" => {
1190            if let Some(val) = &assertion.value {
1191                if let Some(n) = val.as_u64() {
1192                    let _ = writeln!(out, "      assert length({field_expr}) >= {n}");
1193                }
1194            }
1195        }
1196        "count_equals" => {
1197            if let Some(val) = &assertion.value {
1198                if let Some(n) = val.as_u64() {
1199                    let _ = writeln!(out, "      assert length({field_expr}) == {n}");
1200                }
1201            }
1202        }
1203        "is_true" => {
1204            let _ = writeln!(out, "      assert {field_expr} == true");
1205        }
1206        "is_false" => {
1207            let _ = writeln!(out, "      assert {field_expr} == false");
1208        }
1209        "method_result" => {
1210            if let Some(method_name) = &assertion.method {
1211                let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1212                let check = assertion.check.as_deref().unwrap_or("is_true");
1213                match check {
1214                    "equals" => {
1215                        if let Some(val) = &assertion.value {
1216                            let elixir_val = json_to_elixir(val);
1217                            let _ = writeln!(out, "      assert {call_expr} == {elixir_val}");
1218                        }
1219                    }
1220                    "is_true" => {
1221                        let _ = writeln!(out, "      assert {call_expr} == true");
1222                    }
1223                    "is_false" => {
1224                        let _ = writeln!(out, "      assert {call_expr} == false");
1225                    }
1226                    "greater_than_or_equal" => {
1227                        if let Some(val) = &assertion.value {
1228                            let n = val.as_u64().unwrap_or(0);
1229                            let _ = writeln!(out, "      assert {call_expr} >= {n}");
1230                        }
1231                    }
1232                    "count_min" => {
1233                        if let Some(val) = &assertion.value {
1234                            let n = val.as_u64().unwrap_or(0);
1235                            let _ = writeln!(out, "      assert length({call_expr}) >= {n}");
1236                        }
1237                    }
1238                    "contains" => {
1239                        if let Some(val) = &assertion.value {
1240                            let elixir_val = json_to_elixir(val);
1241                            let _ = writeln!(out, "      assert String.contains?({call_expr}, {elixir_val})");
1242                        }
1243                    }
1244                    "is_error" => {
1245                        let _ = writeln!(out, "      assert_raise RuntimeError, fn -> {call_expr} end");
1246                    }
1247                    other_check => {
1248                        panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1249                    }
1250                }
1251            } else {
1252                panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1253            }
1254        }
1255        "matches_regex" => {
1256            if let Some(expected) = &assertion.value {
1257                let elixir_val = json_to_elixir(expected);
1258                let _ = writeln!(out, "      assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1259            }
1260        }
1261        "not_error" => {
1262            // Already handled — the call would fail if it returned {:error, _}.
1263        }
1264        "error" => {
1265            // Handled at the test level.
1266        }
1267        other => {
1268            panic!("Elixir e2e generator: unsupported assertion type: {other}");
1269        }
1270    }
1271}
1272
1273/// Build an Elixir call expression for a `method_result` assertion on a tree-sitter result.
1274/// Maps method names to the appropriate `module_path` function calls.
1275fn build_elixir_method_call(
1276    result_var: &str,
1277    method_name: &str,
1278    args: Option<&serde_json::Value>,
1279    module_path: &str,
1280) -> String {
1281    match method_name {
1282        "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1283        "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1284        "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1285        "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1286        "contains_node_type" => {
1287            let node_type = args
1288                .and_then(|a| a.get("node_type"))
1289                .and_then(|v| v.as_str())
1290                .unwrap_or("");
1291            format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1292        }
1293        "find_nodes_by_type" => {
1294            let node_type = args
1295                .and_then(|a| a.get("node_type"))
1296                .and_then(|v| v.as_str())
1297                .unwrap_or("");
1298            format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1299        }
1300        "run_query" => {
1301            let query_source = args
1302                .and_then(|a| a.get("query_source"))
1303                .and_then(|v| v.as_str())
1304                .unwrap_or("");
1305            let language = args
1306                .and_then(|a| a.get("language"))
1307                .and_then(|v| v.as_str())
1308                .unwrap_or("");
1309            format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1310        }
1311        _ => format!("{module_path}.{method_name}({result_var})"),
1312    }
1313}
1314
1315/// Convert a category name to an Elixir module-safe PascalCase name.
1316fn elixir_module_name(category: &str) -> String {
1317    use heck::ToUpperCamelCase;
1318    category.to_upper_camel_case()
1319}
1320
1321/// Convert a `serde_json::Value` to an Elixir literal string.
1322fn json_to_elixir(value: &serde_json::Value) -> String {
1323    match value {
1324        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1325        serde_json::Value::Bool(true) => "true".to_string(),
1326        serde_json::Value::Bool(false) => "false".to_string(),
1327        serde_json::Value::Number(n) => {
1328            // Elixir requires floats to have a decimal point and does not accept
1329            // `e+N` exponent notation. Strip the `+` and ensure there is a decimal
1330            // point before any `e` exponent marker (e.g. `1e-10` → `1.0e-10`).
1331            let s = n.to_string().replace("e+", "e");
1332            if s.contains('e') && !s.contains('.') {
1333                // Insert `.0` before the `e` so Elixir treats this as a float.
1334                s.replacen('e', ".0e", 1)
1335            } else {
1336                s
1337            }
1338        }
1339        serde_json::Value::Null => "nil".to_string(),
1340        serde_json::Value::Array(arr) => {
1341            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1342            format!("[{}]", items.join(", "))
1343        }
1344        serde_json::Value::Object(map) => {
1345            let entries: Vec<String> = map
1346                .iter()
1347                .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1348                .collect();
1349            format!("%{{{}}}", entries.join(", "))
1350        }
1351    }
1352}
1353
1354/// Build an Elixir visitor map and add setup line. Returns the visitor variable name.
1355fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1356    use std::fmt::Write as FmtWrite;
1357    let mut visitor_obj = String::new();
1358    let _ = writeln!(visitor_obj, "%{{");
1359    for (method_name, action) in &visitor_spec.callbacks {
1360        emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1361    }
1362    let _ = writeln!(visitor_obj, "    }}");
1363
1364    setup_lines.push(format!("visitor = {visitor_obj}"));
1365    "visitor".to_string()
1366}
1367
1368/// Emit an Elixir visitor method for a callback action.
1369fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1370    use std::fmt::Write as FmtWrite;
1371
1372    // Elixir uses atom keys and handle_ prefix
1373    let handle_method = format!("handle_{}", &method_name[6..]); // strip "visit_" prefix
1374    let params = match method_name {
1375        "visit_link" => "_ctx, _href, _text, _title",
1376        "visit_image" => "_ctx, _src, _alt, _title",
1377        "visit_heading" => "_ctx, _level, text, _id",
1378        "visit_code_block" => "_ctx, _lang, _code",
1379        "visit_code_inline"
1380        | "visit_strong"
1381        | "visit_emphasis"
1382        | "visit_strikethrough"
1383        | "visit_underline"
1384        | "visit_subscript"
1385        | "visit_superscript"
1386        | "visit_mark"
1387        | "visit_button"
1388        | "visit_summary"
1389        | "visit_figcaption"
1390        | "visit_definition_term"
1391        | "visit_definition_description" => "_ctx, _text",
1392        "visit_text" => "_ctx, _text",
1393        "visit_list_item" => "_ctx, _ordered, _marker, _text",
1394        "visit_blockquote" => "_ctx, _content, _depth",
1395        "visit_table_row" => "_ctx, _cells, _is_header",
1396        "visit_custom_element" => "_ctx, _tag_name, _html",
1397        "visit_form" => "_ctx, _action_url, _method",
1398        "visit_input" => "_ctx, _input_type, _name, _value",
1399        "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1400        "visit_details" => "_ctx, _is_open",
1401        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "_ctx, _output",
1402        "visit_list_start" => "_ctx, _ordered",
1403        "visit_list_end" => "_ctx, _ordered, _output",
1404        _ => "_ctx",
1405    };
1406
1407    let _ = writeln!(out, "      :{handle_method} => fn({params}) ->");
1408    match action {
1409        CallbackAction::Skip => {
1410            let _ = writeln!(out, "        :skip");
1411        }
1412        CallbackAction::Continue => {
1413            let _ = writeln!(out, "        :continue");
1414        }
1415        CallbackAction::PreserveHtml => {
1416            let _ = writeln!(out, "        :preserve_html");
1417        }
1418        CallbackAction::Custom { output } => {
1419            let escaped = escape_elixir(output);
1420            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1421        }
1422        CallbackAction::CustomTemplate { template } => {
1423            // For template, use string interpolation in Elixir (but simplified without arg binding)
1424            let escaped = escape_elixir(template);
1425            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1426        }
1427    }
1428    let _ = writeln!(out, "      end,");
1429}