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