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/// Render an ExUnit `describe` + `test` block for an HTTP server test fixture.
309fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
310    let test_name = sanitize_ident(&fixture.id);
311    let description = fixture.description.replace('"', "\\\"");
312    let method = http.request.method.to_uppercase();
313    let path = &http.request.path;
314    let fixture_id = &fixture.id;
315
316    let _ = writeln!(out, "  describe \"{test_name}\" do");
317    let _ = writeln!(out, "    test \"{method} {path} - {description}\" do");
318
319    // Build request targeting the mock server.
320    render_elixir_http_request(out, &http.request, fixture_id);
321
322    // Assert status.
323    let status = http.expected_response.status_code;
324    let _ = writeln!(out, "      assert response.status == {status}");
325
326    // Assert body.
327    render_elixir_body_assertions(out, &http.expected_response);
328
329    // Assert headers.
330    render_elixir_header_assertions(out, &http.expected_response);
331
332    let _ = writeln!(out, "    end");
333    let _ = writeln!(out, "  end");
334}
335
336/// HTTP methods that Req exposes as convenience functions.
337/// All others must be called via `Req.request(method: :METHOD, ...)`.
338const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
339
340/// Emit Req request lines inside an ExUnit test.
341fn render_elixir_http_request(out: &mut String, req: &HttpRequest, fixture_id: &str) {
342    let method = req.method.to_lowercase();
343
344    let mut opts: Vec<String> = Vec::new();
345
346    if let Some(body) = &req.body {
347        let elixir_val = json_to_elixir(body);
348        opts.push(format!("json: {elixir_val}"));
349    }
350
351    if !req.headers.is_empty() {
352        let header_pairs: Vec<String> = req
353            .headers
354            .iter()
355            .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
356            .collect();
357        opts.push(format!("headers: [{}]", header_pairs.join(", ")));
358    }
359
360    if !req.cookies.is_empty() {
361        let cookie_str = req
362            .cookies
363            .iter()
364            .map(|(k, v)| format!("{}={}", k, v))
365            .collect::<Vec<_>>()
366            .join("; ");
367        opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
368    }
369
370    if !req.query_params.is_empty() {
371        let pairs: Vec<String> = req
372            .query_params
373            .iter()
374            .map(|(k, v)| {
375                let val_str = match v {
376                    serde_json::Value::String(s) => s.clone(),
377                    other => other.to_string(),
378                };
379                format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
380            })
381            .collect();
382        opts.push(format!("params: [{}]", pairs.join(", ")));
383    }
384
385    // Use the mock server's /fixtures/<id> endpoint.
386    let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{}\"", escape_elixir(fixture_id));
387
388    // Req only exposes convenience functions for common HTTP verbs.
389    // Less common methods (OPTIONS, CONNECT, TRACE, etc.) must use Req.request/1.
390    if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
391        if opts.is_empty() {
392            let _ = writeln!(out, "      {{:ok, response}} = Req.{method}(url: {url_expr})");
393        } else {
394            let opts_str = opts.join(", ");
395            let _ = writeln!(
396                out,
397                "      {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
398            );
399        }
400    } else {
401        // Fall back to Req.request/1 for non-standard methods.
402        opts.insert(0, format!("method: :{method}"));
403        opts.insert(1, format!("url: {url_expr}"));
404        let opts_str = opts.join(", ");
405        let _ = writeln!(out, "      {{:ok, response}} = Req.request({opts_str})");
406    }
407}
408
409/// Emit body assertions for an HTTP expected response.
410fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
411    if let Some(body) = &expected.body {
412        let elixir_val = json_to_elixir(body);
413        // Req auto-decodes JSON bodies; compare the decoded map directly.
414        let _ = writeln!(out, "      assert response.body == {elixir_val}");
415    }
416    if let Some(partial) = &expected.body_partial {
417        if let Some(obj) = partial.as_object() {
418            // Req auto-decodes JSON bodies; `response.body` is already a map.
419            let _ = writeln!(out, "      decoded_body = response.body");
420            for (key, val) in obj {
421                let key_lit = format!("\"{}\"", escape_elixir(key));
422                let elixir_val = json_to_elixir(val);
423                let _ = writeln!(out, "      assert decoded_body[{key_lit}] == {elixir_val}");
424            }
425        }
426    }
427    if let Some(errors) = &expected.validation_errors {
428        for err in errors {
429            let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
430            let _ = writeln!(
431                out,
432                "      assert String.contains?(Jason.encode!(response.body), {msg_lit})"
433            );
434        }
435    }
436}
437
438/// Emit header assertions for an HTTP expected response.
439///
440/// Special tokens:
441/// - `"<<present>>"` — assert the header key exists
442/// - `"<<absent>>"` — assert the header key is absent
443/// - `"<<uuid>>"` — assert the header value matches a UUID regex
444fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
445    for (name, value) in &expected.headers {
446        let header_key = name.to_lowercase();
447        let key_lit = format!("\"{}\"", escape_elixir(&header_key));
448        // Req (via Mint) stores header values as lists; extract the first value.
449        let get_header_expr = format!(
450            "Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
451        );
452        match value.as_str() {
453            "<<present>>" => {
454                let _ = writeln!(out, "      assert {get_header_expr} != nil");
455            }
456            "<<absent>>" => {
457                let _ = writeln!(out, "      assert {get_header_expr} == nil");
458            }
459            "<<uuid>>" => {
460                let _ = writeln!(
461                    out,
462                    "      header_val_{} = {get_header_expr}",
463                    sanitize_ident(&header_key)
464                );
465                let _ = writeln!(
466                    out,
467                    "      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_{}))",
468                    sanitize_ident(&header_key)
469                );
470            }
471            literal => {
472                let val_lit = format!("\"{}\"", escape_elixir(literal));
473                let _ = writeln!(out, "      assert {get_header_expr} == {val_lit}");
474            }
475        }
476    }
477}
478
479// ---------------------------------------------------------------------------
480// Function-call test rendering
481// ---------------------------------------------------------------------------
482
483#[allow(clippy::too_many_arguments)]
484fn render_test_case(
485    out: &mut String,
486    fixture: &Fixture,
487    e2e_config: &E2eConfig,
488    default_module_path: &str,
489    default_function_name: &str,
490    default_result_var: &str,
491    args: &[crate::config::ArgMapping],
492    field_resolver: &FieldResolver,
493    options_type: Option<&str>,
494    options_default_fn: Option<&str>,
495    enum_fields: &HashMap<String, String>,
496    handle_struct_type: Option<&str>,
497    handle_atom_list_fields: &std::collections::HashSet<String>,
498) {
499    let test_name = sanitize_ident(&fixture.id);
500    let description = fixture.description.replace('"', "\\\"");
501
502    // Resolve per-fixture call config (falls back to default if fixture.call is None).
503    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
504    let lang = "elixir";
505    let call_overrides = call_config.overrides.get(lang);
506
507    // Compute module_path and function_name from the resolved call config,
508    // applying Elixir-specific PascalCase conversion.
509    let (module_path, function_name, result_var) = if fixture.call.is_some() {
510        let raw_module = call_overrides
511            .and_then(|o| o.module.as_ref())
512            .cloned()
513            .unwrap_or_else(|| call_config.module.clone());
514        let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
515        {
516            raw_module.clone()
517        } else {
518            elixir_module_name(&raw_module)
519        };
520        let base_fn = call_overrides
521            .and_then(|o| o.function.as_ref())
522            .cloned()
523            .unwrap_or_else(|| call_config.function.clone());
524        let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
525            format!("{base_fn}_async")
526        } else {
527            base_fn
528        };
529        (resolved_module, resolved_fn, call_config.result_var.clone())
530    } else {
531        (
532            default_module_path.to_string(),
533            default_function_name.to_string(),
534            default_result_var.to_string(),
535        )
536    };
537
538    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
539
540    let (mut setup_lines, args_str) = build_args_and_setup(
541        &fixture.input,
542        args,
543        &module_path,
544        options_type,
545        options_default_fn,
546        enum_fields,
547        &fixture.id,
548        handle_struct_type,
549        handle_atom_list_fields,
550    );
551
552    // Build visitor if present — must happen before emitting setup_lines so the
553    // visitor setup line is included in the loop below.
554    let final_args = if let Some(visitor_spec) = &fixture.visitor {
555        let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
556        format!("{args_str}, {visitor_var}")
557    } else {
558        args_str
559    };
560
561    let _ = writeln!(out, "  describe \"{test_name}\" do");
562    let _ = writeln!(out, "    test \"{description}\" do");
563
564    for line in &setup_lines {
565        let _ = writeln!(out, "      {line}");
566    }
567
568    if expects_error {
569        let _ = writeln!(
570            out,
571            "      assert {{:error, _}} = {module_path}.{function_name}({final_args})"
572        );
573        let _ = writeln!(out, "    end");
574        let _ = writeln!(out, "  end");
575        return;
576    }
577
578    let _ = writeln!(
579        out,
580        "      {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
581    );
582
583    for assertion in &fixture.assertions {
584        render_assertion(out, assertion, &result_var, field_resolver, &module_path);
585    }
586
587    let _ = writeln!(out, "    end");
588    let _ = writeln!(out, "  end");
589}
590
591/// Build setup lines (e.g. handle creation) and the argument list for the function call.
592///
593/// Returns `(setup_lines, args_string)`.
594#[allow(clippy::too_many_arguments)]
595fn build_args_and_setup(
596    input: &serde_json::Value,
597    args: &[crate::config::ArgMapping],
598    module_path: &str,
599    options_type: Option<&str>,
600    options_default_fn: Option<&str>,
601    enum_fields: &HashMap<String, String>,
602    fixture_id: &str,
603    _handle_struct_type: Option<&str>,
604    _handle_atom_list_fields: &std::collections::HashSet<String>,
605) -> (Vec<String>, String) {
606    if args.is_empty() {
607        return (Vec::new(), json_to_elixir(input));
608    }
609
610    let mut setup_lines: Vec<String> = Vec::new();
611    let mut parts: Vec<String> = Vec::new();
612
613    for arg in args {
614        if arg.arg_type == "mock_url" {
615            setup_lines.push(format!(
616                "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
617                arg.name,
618            ));
619            parts.push(arg.name.clone());
620            continue;
621        }
622
623        if arg.arg_type == "handle" {
624            // Generate a create_{name} call using {:ok, name} = ... pattern.
625            // The NIF now accepts config as an optional JSON string (not a NifStruct/NifMap)
626            // so that partial maps work: serde_json::from_str respects #[serde(default)].
627            let constructor_name = format!("create_{}", arg.name.to_snake_case());
628            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
629            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
630            let name = &arg.name;
631            if config_value.is_null()
632                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
633            {
634                setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
635            } else {
636                // Serialize the config map to a JSON string with Jason so that Rust can
637                // deserialize it with serde_json and apply field defaults for missing keys.
638                let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
639                let escaped = escape_elixir(&json_str);
640                setup_lines.push(format!("{name}_config = \"{escaped}\""));
641                setup_lines.push(format!(
642                    "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
643                ));
644            }
645            parts.push(arg.name.clone());
646            continue;
647        }
648
649        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
650        let val = input.get(field);
651        match val {
652            None | Some(serde_json::Value::Null) if arg.optional => {
653                // Optional arg with no fixture value: skip entirely.
654                continue;
655            }
656            None | Some(serde_json::Value::Null) => {
657                // Required arg with no fixture value: pass a language-appropriate default.
658                let default_val = match arg.arg_type.as_str() {
659                    "string" => "\"\"".to_string(),
660                    "int" | "integer" => "0".to_string(),
661                    "float" | "number" => "0.0".to_string(),
662                    "bool" | "boolean" => "false".to_string(),
663                    _ => "nil".to_string(),
664                };
665                parts.push(default_val);
666            }
667            Some(v) => {
668                // For json_object args with options_type, build a proper struct.
669                if arg.arg_type == "json_object" && !v.is_null() {
670                    if let (Some(_opts_type), Some(options_fn), Some(obj)) =
671                        (options_type, options_default_fn, v.as_object())
672                    {
673                        // Add setup line to initialize options from default function.
674                        let options_var = "options";
675                        setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
676
677                        // For each field in the options object, add a struct update line.
678                        for (k, vv) in obj.iter() {
679                            let snake_key = k.to_snake_case();
680                            let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
681                                if let Some(s) = vv.as_str() {
682                                    let snake_val = s.to_snake_case();
683                                    // Use atom for enum values, not string
684                                    format!(":{snake_val}")
685                                } else {
686                                    json_to_elixir(vv)
687                                }
688                            } else {
689                                json_to_elixir(vv)
690                            };
691                            setup_lines.push(format!(
692                                "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
693                            ));
694                        }
695
696                        // Push the variable name as the argument.
697                        parts.push(options_var.to_string());
698                        continue;
699                    }
700                }
701                parts.push(json_to_elixir(v));
702            }
703        }
704    }
705
706    (setup_lines, parts.join(", "))
707}
708
709/// Returns true if the field expression is a numeric/integer expression
710/// (e.g., a `length(...)` call) rather than a string.
711fn is_numeric_expr(field_expr: &str) -> bool {
712    field_expr.starts_with("length(")
713}
714
715fn render_assertion(
716    out: &mut String,
717    assertion: &Assertion,
718    result_var: &str,
719    field_resolver: &FieldResolver,
720    module_path: &str,
721) {
722    // Skip assertions on fields that don't exist on the result type.
723    if let Some(f) = &assertion.field {
724        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
725            let _ = writeln!(out, "      # skipped: field '{f}' not available on result type");
726            return;
727        }
728    }
729
730    let field_expr = match &assertion.field {
731        Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
732        _ => result_var.to_string(),
733    };
734
735    // Only wrap in String.trim/0 when the expression is actually a string.
736    // Numeric expressions (e.g., length(...)) must not be wrapped.
737    let is_numeric = is_numeric_expr(&field_expr);
738    let trimmed_field_expr = if is_numeric {
739        field_expr.clone()
740    } else {
741        format!("String.trim({field_expr})")
742    };
743
744    match assertion.assertion_type.as_str() {
745        "equals" => {
746            if let Some(expected) = &assertion.value {
747                let elixir_val = json_to_elixir(expected);
748                // Apply String.trim only for string comparisons, not numeric ones.
749                let is_string_expected = expected.is_string();
750                if is_string_expected && !is_numeric {
751                    let _ = writeln!(out, "      assert {trimmed_field_expr} == {elixir_val}");
752                } else {
753                    let _ = writeln!(out, "      assert {field_expr} == {elixir_val}");
754                }
755            }
756        }
757        "contains" => {
758            if let Some(expected) = &assertion.value {
759                let elixir_val = json_to_elixir(expected);
760                // Use to_string() to handle atoms (enums) as well as strings
761                let _ = writeln!(
762                    out,
763                    "      assert String.contains?(to_string({field_expr}), {elixir_val})"
764                );
765            }
766        }
767        "contains_all" => {
768            if let Some(values) = &assertion.values {
769                for val in values {
770                    let elixir_val = json_to_elixir(val);
771                    let _ = writeln!(
772                        out,
773                        "      assert String.contains?(to_string({field_expr}), {elixir_val})"
774                    );
775                }
776            }
777        }
778        "not_contains" => {
779            if let Some(expected) = &assertion.value {
780                let elixir_val = json_to_elixir(expected);
781                let _ = writeln!(
782                    out,
783                    "      refute String.contains?(to_string({field_expr}), {elixir_val})"
784                );
785            }
786        }
787        "not_empty" => {
788            let _ = writeln!(out, "      assert {field_expr} != \"\"");
789        }
790        "is_empty" => {
791            if is_numeric {
792                // length(...) == 0
793                let _ = writeln!(out, "      assert {field_expr} == 0");
794            } else {
795                // Handle nil (None) as empty
796                let _ = writeln!(out, "      assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
797            }
798        }
799        "contains_any" => {
800            if let Some(values) = &assertion.values {
801                let items: Vec<String> = values.iter().map(json_to_elixir).collect();
802                let list_str = items.join(", ");
803                let _ = writeln!(
804                    out,
805                    "      assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
806                );
807            }
808        }
809        "greater_than" => {
810            if let Some(val) = &assertion.value {
811                let elixir_val = json_to_elixir(val);
812                let _ = writeln!(out, "      assert {field_expr} > {elixir_val}");
813            }
814        }
815        "less_than" => {
816            if let Some(val) = &assertion.value {
817                let elixir_val = json_to_elixir(val);
818                let _ = writeln!(out, "      assert {field_expr} < {elixir_val}");
819            }
820        }
821        "greater_than_or_equal" => {
822            if let Some(val) = &assertion.value {
823                let elixir_val = json_to_elixir(val);
824                let _ = writeln!(out, "      assert {field_expr} >= {elixir_val}");
825            }
826        }
827        "less_than_or_equal" => {
828            if let Some(val) = &assertion.value {
829                let elixir_val = json_to_elixir(val);
830                let _ = writeln!(out, "      assert {field_expr} <= {elixir_val}");
831            }
832        }
833        "starts_with" => {
834            if let Some(expected) = &assertion.value {
835                let elixir_val = json_to_elixir(expected);
836                let _ = writeln!(out, "      assert String.starts_with?({field_expr}, {elixir_val})");
837            }
838        }
839        "ends_with" => {
840            if let Some(expected) = &assertion.value {
841                let elixir_val = json_to_elixir(expected);
842                let _ = writeln!(out, "      assert String.ends_with?({field_expr}, {elixir_val})");
843            }
844        }
845        "min_length" => {
846            if let Some(val) = &assertion.value {
847                if let Some(n) = val.as_u64() {
848                    let _ = writeln!(out, "      assert String.length({field_expr}) >= {n}");
849                }
850            }
851        }
852        "max_length" => {
853            if let Some(val) = &assertion.value {
854                if let Some(n) = val.as_u64() {
855                    let _ = writeln!(out, "      assert String.length({field_expr}) <= {n}");
856                }
857            }
858        }
859        "count_min" => {
860            if let Some(val) = &assertion.value {
861                if let Some(n) = val.as_u64() {
862                    let _ = writeln!(out, "      assert length({field_expr}) >= {n}");
863                }
864            }
865        }
866        "count_equals" => {
867            if let Some(val) = &assertion.value {
868                if let Some(n) = val.as_u64() {
869                    let _ = writeln!(out, "      assert length({field_expr}) == {n}");
870                }
871            }
872        }
873        "is_true" => {
874            let _ = writeln!(out, "      assert {field_expr} == true");
875        }
876        "is_false" => {
877            let _ = writeln!(out, "      assert {field_expr} == false");
878        }
879        "method_result" => {
880            if let Some(method_name) = &assertion.method {
881                let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
882                let check = assertion.check.as_deref().unwrap_or("is_true");
883                match check {
884                    "equals" => {
885                        if let Some(val) = &assertion.value {
886                            let elixir_val = json_to_elixir(val);
887                            let _ = writeln!(out, "      assert {call_expr} == {elixir_val}");
888                        }
889                    }
890                    "is_true" => {
891                        let _ = writeln!(out, "      assert {call_expr} == true");
892                    }
893                    "is_false" => {
894                        let _ = writeln!(out, "      assert {call_expr} == false");
895                    }
896                    "greater_than_or_equal" => {
897                        if let Some(val) = &assertion.value {
898                            let n = val.as_u64().unwrap_or(0);
899                            let _ = writeln!(out, "      assert {call_expr} >= {n}");
900                        }
901                    }
902                    "count_min" => {
903                        if let Some(val) = &assertion.value {
904                            let n = val.as_u64().unwrap_or(0);
905                            let _ = writeln!(out, "      assert length({call_expr}) >= {n}");
906                        }
907                    }
908                    "contains" => {
909                        if let Some(val) = &assertion.value {
910                            let elixir_val = json_to_elixir(val);
911                            let _ = writeln!(out, "      assert String.contains?({call_expr}, {elixir_val})");
912                        }
913                    }
914                    "is_error" => {
915                        let _ = writeln!(out, "      assert_raise RuntimeError, fn -> {call_expr} end");
916                    }
917                    other_check => {
918                        panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
919                    }
920                }
921            } else {
922                panic!("Elixir e2e generator: method_result assertion missing 'method' field");
923            }
924        }
925        "matches_regex" => {
926            if let Some(expected) = &assertion.value {
927                let elixir_val = json_to_elixir(expected);
928                let _ = writeln!(out, "      assert Regex.match?(~r/{elixir_val}/, {field_expr})");
929            }
930        }
931        "not_error" => {
932            // Already handled — the call would fail if it returned {:error, _}.
933        }
934        "error" => {
935            // Handled at the test level.
936        }
937        other => {
938            panic!("Elixir e2e generator: unsupported assertion type: {other}");
939        }
940    }
941}
942
943/// Build an Elixir call expression for a `method_result` assertion on a tree-sitter result.
944/// Maps method names to the appropriate `module_path` function calls.
945fn build_elixir_method_call(
946    result_var: &str,
947    method_name: &str,
948    args: Option<&serde_json::Value>,
949    module_path: &str,
950) -> String {
951    match method_name {
952        "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
953        "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
954        "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
955        "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
956        "contains_node_type" => {
957            let node_type = args
958                .and_then(|a| a.get("node_type"))
959                .and_then(|v| v.as_str())
960                .unwrap_or("");
961            format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
962        }
963        "find_nodes_by_type" => {
964            let node_type = args
965                .and_then(|a| a.get("node_type"))
966                .and_then(|v| v.as_str())
967                .unwrap_or("");
968            format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
969        }
970        "run_query" => {
971            let query_source = args
972                .and_then(|a| a.get("query_source"))
973                .and_then(|v| v.as_str())
974                .unwrap_or("");
975            let language = args
976                .and_then(|a| a.get("language"))
977                .and_then(|v| v.as_str())
978                .unwrap_or("");
979            format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
980        }
981        _ => format!("{module_path}.{method_name}({result_var})"),
982    }
983}
984
985/// Convert a category name to an Elixir module-safe PascalCase name.
986fn elixir_module_name(category: &str) -> String {
987    use heck::ToUpperCamelCase;
988    category.to_upper_camel_case()
989}
990
991/// Convert a `serde_json::Value` to an Elixir literal string.
992fn json_to_elixir(value: &serde_json::Value) -> String {
993    match value {
994        serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
995        serde_json::Value::Bool(true) => "true".to_string(),
996        serde_json::Value::Bool(false) => "false".to_string(),
997        serde_json::Value::Number(n) => {
998            // Elixir requires floats to have a decimal point and does not accept
999            // `e+N` exponent notation. Strip the `+` and ensure there is a decimal
1000            // point before any `e` exponent marker (e.g. `1e-10` → `1.0e-10`).
1001            let s = n.to_string().replace("e+", "e");
1002            if s.contains('e') && !s.contains('.') {
1003                // Insert `.0` before the `e` so Elixir treats this as a float.
1004                s.replacen('e', ".0e", 1)
1005            } else {
1006                s
1007            }
1008        }
1009        serde_json::Value::Null => "nil".to_string(),
1010        serde_json::Value::Array(arr) => {
1011            let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1012            format!("[{}]", items.join(", "))
1013        }
1014        serde_json::Value::Object(map) => {
1015            let entries: Vec<String> = map
1016                .iter()
1017                .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1018                .collect();
1019            format!("%{{{}}}", entries.join(", "))
1020        }
1021    }
1022}
1023
1024/// Build an Elixir visitor map and add setup line. Returns the visitor variable name.
1025fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1026    use std::fmt::Write as FmtWrite;
1027    let mut visitor_obj = String::new();
1028    let _ = writeln!(visitor_obj, "%{{");
1029    for (method_name, action) in &visitor_spec.callbacks {
1030        emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1031    }
1032    let _ = writeln!(visitor_obj, "    }}");
1033
1034    setup_lines.push(format!("visitor = {visitor_obj}"));
1035    "visitor".to_string()
1036}
1037
1038/// Emit an Elixir visitor method for a callback action.
1039fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1040    use std::fmt::Write as FmtWrite;
1041
1042    // Elixir uses atom keys and handle_ prefix
1043    let handle_method = format!("handle_{}", &method_name[6..]); // strip "visit_" prefix
1044    let params = match method_name {
1045        "visit_link" => "_ctx, _href, _text, _title",
1046        "visit_image" => "_ctx, _src, _alt, _title",
1047        "visit_heading" => "_ctx, _level, text, _id",
1048        "visit_code_block" => "_ctx, _lang, _code",
1049        "visit_code_inline"
1050        | "visit_strong"
1051        | "visit_emphasis"
1052        | "visit_strikethrough"
1053        | "visit_underline"
1054        | "visit_subscript"
1055        | "visit_superscript"
1056        | "visit_mark"
1057        | "visit_button"
1058        | "visit_summary"
1059        | "visit_figcaption"
1060        | "visit_definition_term"
1061        | "visit_definition_description" => "_ctx, _text",
1062        "visit_text" => "_ctx, _text",
1063        "visit_list_item" => "_ctx, _ordered, _marker, _text",
1064        "visit_blockquote" => "_ctx, _content, _depth",
1065        "visit_table_row" => "_ctx, _cells, _is_header",
1066        "visit_custom_element" => "_ctx, _tag_name, _html",
1067        "visit_form" => "_ctx, _action_url, _method",
1068        "visit_input" => "_ctx, _input_type, _name, _value",
1069        "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1070        "visit_details" => "_ctx, _is_open",
1071        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "_ctx, _output",
1072        "visit_list_start" => "_ctx, _ordered",
1073        "visit_list_end" => "_ctx, _ordered, _output",
1074        _ => "_ctx",
1075    };
1076
1077    let _ = writeln!(out, "      :{handle_method} => fn({params}) ->");
1078    match action {
1079        CallbackAction::Skip => {
1080            let _ = writeln!(out, "        :skip");
1081        }
1082        CallbackAction::Continue => {
1083            let _ = writeln!(out, "        :continue");
1084        }
1085        CallbackAction::PreserveHtml => {
1086            let _ = writeln!(out, "        :preserve_html");
1087        }
1088        CallbackAction::Custom { output } => {
1089            let escaped = escape_elixir(output);
1090            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1091        }
1092        CallbackAction::CustomTemplate { template } => {
1093            // For template, use string interpolation in Elixir (but simplified without arg binding)
1094            let escaped = escape_elixir(template);
1095            let _ = writeln!(out, "        {{:custom, \"{escaped}\"}}");
1096        }
1097    }
1098    let _ = writeln!(out, "      end,");
1099}