1use 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 anyhow::Result;
12use heck::ToSnakeCase;
13use std::collections::HashMap;
14use std::fmt::Write as FmtWrite;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18
19pub struct ElixirCodegen;
21
22impl E2eCodegen for ElixirCodegen {
23 fn generate(
24 &self,
25 groups: &[FixtureGroup],
26 e2e_config: &E2eConfig,
27 _alef_config: &AlefConfig,
28 ) -> Result<Vec<GeneratedFile>> {
29 let lang = self.language_name();
30 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
31
32 let mut files = Vec::new();
33
34 let call = &e2e_config.call;
36 let overrides = call.overrides.get(lang);
37 let raw_module = overrides
38 .and_then(|o| o.module.as_ref())
39 .cloned()
40 .unwrap_or_else(|| call.module.clone());
41 let module_path = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase()) {
45 raw_module.clone()
46 } else {
47 elixir_module_name(&raw_module)
48 };
49 let base_function_name = overrides
50 .and_then(|o| o.function.as_ref())
51 .cloned()
52 .unwrap_or_else(|| call.function.clone());
53 let function_name = if call.r#async && !base_function_name.ends_with("_async") {
56 format!("{base_function_name}_async")
57 } else {
58 base_function_name
59 };
60 let options_type = overrides.and_then(|o| o.options_type.clone());
61 let options_default_fn = overrides.and_then(|o| o.options_via.clone());
62 let empty_enum_fields = HashMap::new();
63 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
64 let handle_struct_type = overrides.and_then(|o| o.handle_struct_type.clone());
65 let empty_atom_fields = std::collections::HashSet::new();
66 let handle_atom_list_fields = overrides
67 .map(|o| &o.handle_atom_list_fields)
68 .unwrap_or(&empty_atom_fields);
69 let result_var = &call.result_var;
70
71 let elixir_pkg = e2e_config.resolve_package("elixir");
73 let pkg_path = elixir_pkg
74 .as_ref()
75 .and_then(|p| p.path.as_ref())
76 .cloned()
77 .unwrap_or_else(|| "../../packages/elixir".to_string());
78 let dep_atom = elixir_pkg
81 .as_ref()
82 .and_then(|p| p.name.as_ref())
83 .cloned()
84 .unwrap_or_else(|| raw_module.to_snake_case());
85 let dep_version = elixir_pkg
86 .as_ref()
87 .and_then(|p| p.version.as_ref())
88 .cloned()
89 .unwrap_or_else(|| "0.1.0".to_string());
90
91 let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
93
94 files.push(GeneratedFile {
96 path: output_base.join("mix.exs"),
97 content: render_mix_exs(&dep_atom, &pkg_path, &dep_version, e2e_config.dep_mode, has_http_tests),
98 generated_header: false,
99 });
100
101 files.push(GeneratedFile {
103 path: output_base.join("lib").join("e2e_elixir.ex"),
104 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
105 generated_header: false,
106 });
107
108 files.push(GeneratedFile {
110 path: output_base.join("test").join("test_helper.exs"),
111 content: "ExUnit.start()\n".to_string(),
112 generated_header: false,
113 });
114
115 for group in groups {
117 let active: Vec<&Fixture> = group
118 .fixtures
119 .iter()
120 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
121 .collect();
122
123 if active.is_empty() {
124 continue;
125 }
126
127 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
128 let field_resolver = FieldResolver::new(
129 &e2e_config.fields,
130 &e2e_config.fields_optional,
131 &e2e_config.result_fields,
132 &e2e_config.fields_array,
133 );
134 let content = render_test_file(
135 &group.category,
136 &active,
137 e2e_config,
138 &module_path,
139 &function_name,
140 result_var,
141 &e2e_config.call.args,
142 &field_resolver,
143 options_type.as_deref(),
144 options_default_fn.as_deref(),
145 enum_fields,
146 handle_struct_type.as_deref(),
147 handle_atom_list_fields,
148 );
149 files.push(GeneratedFile {
150 path: output_base.join("test").join(filename),
151 content,
152 generated_header: true,
153 });
154 }
155
156 Ok(files)
157 }
158
159 fn language_name(&self) -> &'static str {
160 "elixir"
161 }
162}
163
164fn render_mix_exs(
165 dep_atom: &str,
166 pkg_path: &str,
167 dep_version: &str,
168 dep_mode: crate::config::DependencyMode,
169 has_http_tests: bool,
170) -> String {
171 let mut out = String::new();
172 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
173 let _ = writeln!(out, " use Mix.Project");
174 let _ = writeln!(out);
175 let _ = writeln!(out, " def project do");
176 let _ = writeln!(out, " [");
177 let _ = writeln!(out, " app: :e2e_elixir,");
178 let _ = writeln!(out, " version: \"0.1.0\",");
179 let _ = writeln!(out, " elixir: \"~> 1.14\",");
180 let _ = writeln!(out, " deps: deps()");
181 let _ = writeln!(out, " ]");
182 let _ = writeln!(out, " end");
183 let _ = writeln!(out);
184 let _ = writeln!(out, " defp deps do");
185 let _ = writeln!(out, " [");
186 let dep_line = match dep_mode {
188 crate::config::DependencyMode::Registry => {
189 format!(" {{:{dep_atom}, \"{dep_version}\"}}")
190 }
191 crate::config::DependencyMode::Local => {
192 format!(" {{:{dep_atom}, path: \"{pkg_path}\"}}")
193 }
194 };
195 let _ = writeln!(out, "{dep_line}");
196 if has_http_tests {
197 let _ = writeln!(out, " {{:req, \"~> 0.5\"}}");
198 let _ = writeln!(out, " {{:jason, \"~> 1.4\"}}");
199 }
200 let _ = writeln!(out, " ]");
201 let _ = writeln!(out, " end");
202 let _ = writeln!(out, "end");
203 out
204}
205
206#[allow(clippy::too_many_arguments)]
207fn render_test_file(
208 category: &str,
209 fixtures: &[&Fixture],
210 e2e_config: &E2eConfig,
211 module_path: &str,
212 function_name: &str,
213 result_var: &str,
214 args: &[crate::config::ArgMapping],
215 field_resolver: &FieldResolver,
216 options_type: Option<&str>,
217 options_default_fn: Option<&str>,
218 enum_fields: &HashMap<String, String>,
219 handle_struct_type: Option<&str>,
220 handle_atom_list_fields: &std::collections::HashSet<String>,
221) -> String {
222 let mut out = String::new();
223 let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
224 let _ = writeln!(out, "# E2e tests for category: {category}");
225 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
226 let _ = writeln!(out, " use ExUnit.Case, async: true");
227
228 let has_http = fixtures.iter().any(|f| f.is_http_test());
230 if has_http {
231 let _ = writeln!(out);
232 let _ = writeln!(out, " defp base_url do");
233 let _ = writeln!(
234 out,
235 " System.get_env(\"TEST_SERVER_URL\") || \"http://localhost:8080\""
236 );
237 let _ = writeln!(out, " end");
238 let _ = writeln!(out);
239 let _ = writeln!(out, " defp client do");
240 let _ = writeln!(out, " Req.new(base_url: base_url())");
241 let _ = writeln!(out, " end");
242 }
243
244 let _ = writeln!(out);
245
246 for (i, fixture) in fixtures.iter().enumerate() {
247 if let Some(http) = &fixture.http {
248 render_http_test_case(&mut out, fixture, http);
249 } else {
250 render_test_case(
251 &mut out,
252 fixture,
253 e2e_config,
254 module_path,
255 function_name,
256 result_var,
257 args,
258 field_resolver,
259 options_type,
260 options_default_fn,
261 enum_fields,
262 handle_struct_type,
263 handle_atom_list_fields,
264 );
265 }
266 if i + 1 < fixtures.len() {
267 let _ = writeln!(out);
268 }
269 }
270
271 let _ = writeln!(out, "end");
272 out
273}
274
275fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
281 let test_name = sanitize_ident(&fixture.id);
282 let description = fixture.description.replace('"', "\\\"");
283 let method = http.request.method.to_uppercase();
284 let path = &http.request.path;
285
286 let _ = writeln!(out, " describe \"{test_name}\" do");
287 let _ = writeln!(out, " test \"{method} {path} - {description}\" do");
288
289 render_elixir_http_request(out, &http.request);
291
292 let status = http.expected_response.status_code;
294 let _ = writeln!(out, " assert response.status == {status}");
295
296 render_elixir_body_assertions(out, &http.expected_response);
298
299 render_elixir_header_assertions(out, &http.expected_response);
301
302 let _ = writeln!(out, " end");
303 let _ = writeln!(out, " end");
304}
305
306fn render_elixir_http_request(out: &mut String, req: &HttpRequest) {
308 let method = req.method.to_lowercase();
309
310 let mut opts: Vec<String> = Vec::new();
311
312 if let Some(body) = &req.body {
313 let elixir_val = json_to_elixir(body);
314 opts.push(format!("json: {elixir_val}"));
315 }
316
317 if !req.headers.is_empty() {
318 let header_pairs: Vec<String> = req
319 .headers
320 .iter()
321 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
322 .collect();
323 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
324 }
325
326 if !req.cookies.is_empty() {
327 let cookie_str = req
328 .cookies
329 .iter()
330 .map(|(k, v)| format!("{}={}", k, v))
331 .collect::<Vec<_>>()
332 .join("; ");
333 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
334 }
335
336 if !req.query_params.is_empty() {
337 let pairs: Vec<String> = req
338 .query_params
339 .iter()
340 .map(|(k, v)| {
341 let val_str = match v {
342 serde_json::Value::String(s) => s.clone(),
343 other => other.to_string(),
344 };
345 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
346 })
347 .collect();
348 opts.push(format!("params: [{}]", pairs.join(", ")));
349 }
350
351 let path_lit = format!("\"{}\"", escape_elixir(&req.path));
352 if opts.is_empty() {
353 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(client(), url: {path_lit})");
354 } else {
355 let opts_str = opts.join(", ");
356 let _ = writeln!(
357 out,
358 " {{:ok, response}} = Req.{method}(client(), url: {path_lit}, {opts_str})"
359 );
360 }
361}
362
363fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
365 if let Some(body) = &expected.body {
366 let elixir_val = json_to_elixir(body);
367 let _ = writeln!(out, " assert Jason.decode!(response.body) == {elixir_val}");
368 }
369 if let Some(partial) = &expected.body_partial {
370 if let Some(obj) = partial.as_object() {
371 let _ = writeln!(out, " decoded_body = Jason.decode!(response.body)");
372 for (key, val) in obj {
373 let key_lit = format!("\"{}\"", escape_elixir(key));
374 let elixir_val = json_to_elixir(val);
375 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
376 }
377 }
378 }
379 if let Some(errors) = &expected.validation_errors {
380 for err in errors {
381 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
382 let _ = writeln!(
383 out,
384 " assert String.contains?(Jason.encode!(response.body), {msg_lit})"
385 );
386 }
387 }
388}
389
390fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
397 for (name, value) in &expected.headers {
398 let header_key = name.to_lowercase();
399 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
400 let get_header_expr =
402 format!("Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: v end)");
403 match value.as_str() {
404 "<<present>>" => {
405 let _ = writeln!(out, " assert {get_header_expr} != nil");
406 }
407 "<<absent>>" => {
408 let _ = writeln!(out, " assert {get_header_expr} == nil");
409 }
410 "<<uuid>>" => {
411 let _ = writeln!(
412 out,
413 " header_val_{} = {get_header_expr}",
414 sanitize_ident(&header_key)
415 );
416 let _ = writeln!(
417 out,
418 " 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_{}))",
419 sanitize_ident(&header_key)
420 );
421 }
422 literal => {
423 let val_lit = format!("\"{}\"", escape_elixir(literal));
424 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
425 }
426 }
427 }
428}
429
430#[allow(clippy::too_many_arguments)]
435fn render_test_case(
436 out: &mut String,
437 fixture: &Fixture,
438 e2e_config: &E2eConfig,
439 default_module_path: &str,
440 default_function_name: &str,
441 default_result_var: &str,
442 args: &[crate::config::ArgMapping],
443 field_resolver: &FieldResolver,
444 options_type: Option<&str>,
445 options_default_fn: Option<&str>,
446 enum_fields: &HashMap<String, String>,
447 handle_struct_type: Option<&str>,
448 handle_atom_list_fields: &std::collections::HashSet<String>,
449) {
450 let test_name = sanitize_ident(&fixture.id);
451 let description = fixture.description.replace('"', "\\\"");
452
453 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
455 let lang = "elixir";
456 let call_overrides = call_config.overrides.get(lang);
457
458 let (module_path, function_name, result_var) = if fixture.call.is_some() {
461 let raw_module = call_overrides
462 .and_then(|o| o.module.as_ref())
463 .cloned()
464 .unwrap_or_else(|| call_config.module.clone());
465 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
466 {
467 raw_module.clone()
468 } else {
469 elixir_module_name(&raw_module)
470 };
471 let base_fn = call_overrides
472 .and_then(|o| o.function.as_ref())
473 .cloned()
474 .unwrap_or_else(|| call_config.function.clone());
475 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
476 format!("{base_fn}_async")
477 } else {
478 base_fn
479 };
480 (resolved_module, resolved_fn, call_config.result_var.clone())
481 } else {
482 (
483 default_module_path.to_string(),
484 default_function_name.to_string(),
485 default_result_var.to_string(),
486 )
487 };
488
489 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
490
491 let (mut setup_lines, args_str) = build_args_and_setup(
492 &fixture.input,
493 args,
494 &module_path,
495 options_type,
496 options_default_fn,
497 enum_fields,
498 &fixture.id,
499 handle_struct_type,
500 handle_atom_list_fields,
501 );
502
503 let final_args = if let Some(visitor_spec) = &fixture.visitor {
506 let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
507 format!("{args_str}, {visitor_var}")
508 } else {
509 args_str
510 };
511
512 let _ = writeln!(out, " describe \"{test_name}\" do");
513 let _ = writeln!(out, " test \"{description}\" do");
514
515 for line in &setup_lines {
516 let _ = writeln!(out, " {line}");
517 }
518
519 if expects_error {
520 let _ = writeln!(
521 out,
522 " assert {{:error, _}} = {module_path}.{function_name}({final_args})"
523 );
524 let _ = writeln!(out, " end");
525 let _ = writeln!(out, " end");
526 return;
527 }
528
529 let _ = writeln!(
530 out,
531 " {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
532 );
533
534 for assertion in &fixture.assertions {
535 render_assertion(out, assertion, &result_var, field_resolver, &module_path);
536 }
537
538 let _ = writeln!(out, " end");
539 let _ = writeln!(out, " end");
540}
541
542#[allow(clippy::too_many_arguments)]
546fn build_args_and_setup(
547 input: &serde_json::Value,
548 args: &[crate::config::ArgMapping],
549 module_path: &str,
550 options_type: Option<&str>,
551 options_default_fn: Option<&str>,
552 enum_fields: &HashMap<String, String>,
553 fixture_id: &str,
554 _handle_struct_type: Option<&str>,
555 _handle_atom_list_fields: &std::collections::HashSet<String>,
556) -> (Vec<String>, String) {
557 if args.is_empty() {
558 return (Vec::new(), json_to_elixir(input));
559 }
560
561 let mut setup_lines: Vec<String> = Vec::new();
562 let mut parts: Vec<String> = Vec::new();
563
564 for arg in args {
565 if arg.arg_type == "mock_url" {
566 setup_lines.push(format!(
567 "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
568 arg.name,
569 ));
570 parts.push(arg.name.clone());
571 continue;
572 }
573
574 if arg.arg_type == "handle" {
575 let constructor_name = format!("create_{}", arg.name.to_snake_case());
579 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
580 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
581 let name = &arg.name;
582 if config_value.is_null()
583 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
584 {
585 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
586 } else {
587 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
590 let escaped = escape_elixir(&json_str);
591 setup_lines.push(format!("{name}_config = \"{escaped}\""));
592 setup_lines.push(format!(
593 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
594 ));
595 }
596 parts.push(arg.name.clone());
597 continue;
598 }
599
600 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
601 let val = input.get(field);
602 match val {
603 None | Some(serde_json::Value::Null) if arg.optional => {
604 continue;
606 }
607 None | Some(serde_json::Value::Null) => {
608 let default_val = match arg.arg_type.as_str() {
610 "string" => "\"\"".to_string(),
611 "int" | "integer" => "0".to_string(),
612 "float" | "number" => "0.0".to_string(),
613 "bool" | "boolean" => "false".to_string(),
614 _ => "nil".to_string(),
615 };
616 parts.push(default_val);
617 }
618 Some(v) => {
619 if arg.arg_type == "json_object" && !v.is_null() {
621 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
622 (options_type, options_default_fn, v.as_object())
623 {
624 let options_var = "options";
626 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
627
628 for (k, vv) in obj.iter() {
630 let snake_key = k.to_snake_case();
631 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
632 if let Some(s) = vv.as_str() {
633 let snake_val = s.to_snake_case();
634 format!(":{snake_val}")
636 } else {
637 json_to_elixir(vv)
638 }
639 } else {
640 json_to_elixir(vv)
641 };
642 setup_lines.push(format!(
643 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
644 ));
645 }
646
647 parts.push(options_var.to_string());
649 continue;
650 }
651 }
652 parts.push(json_to_elixir(v));
653 }
654 }
655 }
656
657 (setup_lines, parts.join(", "))
658}
659
660fn is_numeric_expr(field_expr: &str) -> bool {
663 field_expr.starts_with("length(")
664}
665
666fn render_assertion(
667 out: &mut String,
668 assertion: &Assertion,
669 result_var: &str,
670 field_resolver: &FieldResolver,
671 module_path: &str,
672) {
673 if let Some(f) = &assertion.field {
675 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
676 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
677 return;
678 }
679 }
680
681 let field_expr = match &assertion.field {
682 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
683 _ => result_var.to_string(),
684 };
685
686 let is_numeric = is_numeric_expr(&field_expr);
689 let trimmed_field_expr = if is_numeric {
690 field_expr.clone()
691 } else {
692 format!("String.trim({field_expr})")
693 };
694
695 match assertion.assertion_type.as_str() {
696 "equals" => {
697 if let Some(expected) = &assertion.value {
698 let elixir_val = json_to_elixir(expected);
699 let is_string_expected = expected.is_string();
701 if is_string_expected && !is_numeric {
702 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
703 } else {
704 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
705 }
706 }
707 }
708 "contains" => {
709 if let Some(expected) = &assertion.value {
710 let elixir_val = json_to_elixir(expected);
711 let _ = writeln!(
713 out,
714 " assert String.contains?(to_string({field_expr}), {elixir_val})"
715 );
716 }
717 }
718 "contains_all" => {
719 if let Some(values) = &assertion.values {
720 for val in values {
721 let elixir_val = json_to_elixir(val);
722 let _ = writeln!(
723 out,
724 " assert String.contains?(to_string({field_expr}), {elixir_val})"
725 );
726 }
727 }
728 }
729 "not_contains" => {
730 if let Some(expected) = &assertion.value {
731 let elixir_val = json_to_elixir(expected);
732 let _ = writeln!(
733 out,
734 " refute String.contains?(to_string({field_expr}), {elixir_val})"
735 );
736 }
737 }
738 "not_empty" => {
739 let _ = writeln!(out, " assert {field_expr} != \"\"");
740 }
741 "is_empty" => {
742 if is_numeric {
743 let _ = writeln!(out, " assert {field_expr} == 0");
745 } else {
746 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
748 }
749 }
750 "contains_any" => {
751 if let Some(values) = &assertion.values {
752 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
753 let list_str = items.join(", ");
754 let _ = writeln!(
755 out,
756 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
757 );
758 }
759 }
760 "greater_than" => {
761 if let Some(val) = &assertion.value {
762 let elixir_val = json_to_elixir(val);
763 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
764 }
765 }
766 "less_than" => {
767 if let Some(val) = &assertion.value {
768 let elixir_val = json_to_elixir(val);
769 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
770 }
771 }
772 "greater_than_or_equal" => {
773 if let Some(val) = &assertion.value {
774 let elixir_val = json_to_elixir(val);
775 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
776 }
777 }
778 "less_than_or_equal" => {
779 if let Some(val) = &assertion.value {
780 let elixir_val = json_to_elixir(val);
781 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
782 }
783 }
784 "starts_with" => {
785 if let Some(expected) = &assertion.value {
786 let elixir_val = json_to_elixir(expected);
787 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
788 }
789 }
790 "ends_with" => {
791 if let Some(expected) = &assertion.value {
792 let elixir_val = json_to_elixir(expected);
793 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
794 }
795 }
796 "min_length" => {
797 if let Some(val) = &assertion.value {
798 if let Some(n) = val.as_u64() {
799 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
800 }
801 }
802 }
803 "max_length" => {
804 if let Some(val) = &assertion.value {
805 if let Some(n) = val.as_u64() {
806 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
807 }
808 }
809 }
810 "count_min" => {
811 if let Some(val) = &assertion.value {
812 if let Some(n) = val.as_u64() {
813 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
814 }
815 }
816 }
817 "count_equals" => {
818 if let Some(val) = &assertion.value {
819 if let Some(n) = val.as_u64() {
820 let _ = writeln!(out, " assert length({field_expr}) == {n}");
821 }
822 }
823 }
824 "is_true" => {
825 let _ = writeln!(out, " assert {field_expr} == true");
826 }
827 "is_false" => {
828 let _ = writeln!(out, " assert {field_expr} == false");
829 }
830 "method_result" => {
831 if let Some(method_name) = &assertion.method {
832 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
833 let check = assertion.check.as_deref().unwrap_or("is_true");
834 match check {
835 "equals" => {
836 if let Some(val) = &assertion.value {
837 let elixir_val = json_to_elixir(val);
838 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
839 }
840 }
841 "is_true" => {
842 let _ = writeln!(out, " assert {call_expr} == true");
843 }
844 "is_false" => {
845 let _ = writeln!(out, " assert {call_expr} == false");
846 }
847 "greater_than_or_equal" => {
848 if let Some(val) = &assertion.value {
849 let n = val.as_u64().unwrap_or(0);
850 let _ = writeln!(out, " assert {call_expr} >= {n}");
851 }
852 }
853 "count_min" => {
854 if let Some(val) = &assertion.value {
855 let n = val.as_u64().unwrap_or(0);
856 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
857 }
858 }
859 "contains" => {
860 if let Some(val) = &assertion.value {
861 let elixir_val = json_to_elixir(val);
862 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
863 }
864 }
865 "is_error" => {
866 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
867 }
868 other_check => {
869 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
870 }
871 }
872 } else {
873 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
874 }
875 }
876 "not_error" => {
877 }
879 "error" => {
880 }
882 other => {
883 panic!("Elixir e2e generator: unsupported assertion type: {other}");
884 }
885 }
886}
887
888fn build_elixir_method_call(
891 result_var: &str,
892 method_name: &str,
893 args: Option<&serde_json::Value>,
894 module_path: &str,
895) -> String {
896 match method_name {
897 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
898 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
899 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
900 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
901 "contains_node_type" => {
902 let node_type = args
903 .and_then(|a| a.get("node_type"))
904 .and_then(|v| v.as_str())
905 .unwrap_or("");
906 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
907 }
908 "find_nodes_by_type" => {
909 let node_type = args
910 .and_then(|a| a.get("node_type"))
911 .and_then(|v| v.as_str())
912 .unwrap_or("");
913 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
914 }
915 "run_query" => {
916 let query_source = args
917 .and_then(|a| a.get("query_source"))
918 .and_then(|v| v.as_str())
919 .unwrap_or("");
920 let language = args
921 .and_then(|a| a.get("language"))
922 .and_then(|v| v.as_str())
923 .unwrap_or("");
924 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
925 }
926 _ => format!("{module_path}.{method_name}({result_var})"),
927 }
928}
929
930fn elixir_module_name(category: &str) -> String {
932 use heck::ToUpperCamelCase;
933 category.to_upper_camel_case()
934}
935
936fn json_to_elixir(value: &serde_json::Value) -> String {
938 match value {
939 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
940 serde_json::Value::Bool(true) => "true".to_string(),
941 serde_json::Value::Bool(false) => "false".to_string(),
942 serde_json::Value::Number(n) => n.to_string(),
943 serde_json::Value::Null => "nil".to_string(),
944 serde_json::Value::Array(arr) => {
945 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
946 format!("[{}]", items.join(", "))
947 }
948 serde_json::Value::Object(map) => {
949 let entries: Vec<String> = map
950 .iter()
951 .map(|(k, v)| format!("\"{}\" => {}", k.to_snake_case(), json_to_elixir(v)))
952 .collect();
953 format!("%{{{}}}", entries.join(", "))
954 }
955 }
956}
957
958fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
960 use std::fmt::Write as FmtWrite;
961 let mut visitor_obj = String::new();
962 let _ = writeln!(visitor_obj, "%{{");
963 for (method_name, action) in &visitor_spec.callbacks {
964 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
965 }
966 let _ = writeln!(visitor_obj, " }}");
967
968 setup_lines.push(format!("visitor = {visitor_obj}"));
969 "visitor".to_string()
970}
971
972fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
974 use std::fmt::Write as FmtWrite;
975
976 let handle_method = format!("handle_{}", &method_name[6..]); let params = match method_name {
979 "visit_link" => "_ctx, _href, _text, _title",
980 "visit_image" => "_ctx, _src, _alt, _title",
981 "visit_heading" => "_ctx, _level, text, _id",
982 "visit_code_block" => "_ctx, _lang, _code",
983 "visit_code_inline"
984 | "visit_strong"
985 | "visit_emphasis"
986 | "visit_strikethrough"
987 | "visit_underline"
988 | "visit_subscript"
989 | "visit_superscript"
990 | "visit_mark"
991 | "visit_button"
992 | "visit_summary"
993 | "visit_figcaption"
994 | "visit_definition_term"
995 | "visit_definition_description" => "_ctx, _text",
996 "visit_text" => "_ctx, _text",
997 "visit_list_item" => "_ctx, _ordered, _marker, _text",
998 "visit_blockquote" => "_ctx, _content, _depth",
999 "visit_table_row" => "_ctx, _cells, _is_header",
1000 "visit_custom_element" => "_ctx, _tag_name, _html",
1001 "visit_form" => "_ctx, _action_url, _method",
1002 "visit_input" => "_ctx, _input_type, _name, _value",
1003 "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1004 "visit_details" => "_ctx, _is_open",
1005 _ => "_ctx",
1006 };
1007
1008 let _ = writeln!(out, " :{handle_method} => fn({params}) ->");
1009 match action {
1010 CallbackAction::Skip => {
1011 let _ = writeln!(out, " :skip");
1012 }
1013 CallbackAction::Continue => {
1014 let _ = writeln!(out, " :continue");
1015 }
1016 CallbackAction::PreserveHtml => {
1017 let _ = writeln!(out, " :preserve_html");
1018 }
1019 CallbackAction::Custom { output } => {
1020 let escaped = escape_elixir(output);
1021 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1022 }
1023 CallbackAction::CustomTemplate { template } => {
1024 let escaped = escape_elixir(template);
1026 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1027 }
1028 }
1029 let _ = writeln!(out, " end,");
1030}