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