Skip to main content

alef_e2e/codegen/
elixir.rs

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