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 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
21pub 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 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 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 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 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 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 let has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
95
96 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 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 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 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 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 let _ = writeln!(out, "{dep_line}");
227 if has_http_tests {
228 let _ = writeln!(out, " {{:req, \"{req}\"}}", req = tv::hex::REQ);
229 let _ = writeln!(out, " {{:jason, \"{jason}\"}}", jason = tv::hex::JASON);
230 }
231 let _ = writeln!(out, " ]");
232 let _ = writeln!(out, " end");
233 let _ = writeln!(out, "end");
234 out
235}
236
237#[allow(clippy::too_many_arguments)]
238fn render_test_file(
239 category: &str,
240 fixtures: &[&Fixture],
241 e2e_config: &E2eConfig,
242 module_path: &str,
243 function_name: &str,
244 result_var: &str,
245 args: &[crate::config::ArgMapping],
246 field_resolver: &FieldResolver,
247 options_type: Option<&str>,
248 options_default_fn: Option<&str>,
249 enum_fields: &HashMap<String, String>,
250 handle_struct_type: Option<&str>,
251 handle_atom_list_fields: &std::collections::HashSet<String>,
252) -> String {
253 let mut out = String::new();
254 out.push_str(&hash::header(CommentStyle::Hash));
255 let _ = writeln!(out, "# E2e tests for category: {category}");
256 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
257 let _ = writeln!(out, " use ExUnit.Case, async: true");
258
259 let has_http = fixtures.iter().any(|f| f.is_http_test());
261 if has_http {
262 let _ = writeln!(out);
263 let _ = writeln!(out, " defp mock_server_url do");
264 let _ = writeln!(
265 out,
266 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
267 );
268 let _ = writeln!(out, " end");
269 }
270
271 let _ = writeln!(out);
272
273 for (i, fixture) in fixtures.iter().enumerate() {
274 if let Some(http) = &fixture.http {
275 render_http_test_case(&mut out, fixture, http);
276 } else {
277 render_test_case(
278 &mut out,
279 fixture,
280 e2e_config,
281 module_path,
282 function_name,
283 result_var,
284 args,
285 field_resolver,
286 options_type,
287 options_default_fn,
288 enum_fields,
289 handle_struct_type,
290 handle_atom_list_fields,
291 );
292 }
293 if i + 1 < fixtures.len() {
294 let _ = writeln!(out);
295 }
296 }
297
298 let _ = writeln!(out, "end");
299 out
300}
301
302fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
308 let test_name = sanitize_ident(&fixture.id);
309 let description = fixture.description.replace('"', "\\\"");
310 let method = http.request.method.to_uppercase();
311 let path = &http.request.path;
312 let fixture_id = &fixture.id;
313
314 let _ = writeln!(out, " describe \"{test_name}\" do");
315 let _ = writeln!(out, " test \"{method} {path} - {description}\" do");
316
317 render_elixir_http_request(out, &http.request, fixture_id);
319
320 let status = http.expected_response.status_code;
322 let _ = writeln!(out, " assert response.status == {status}");
323
324 render_elixir_body_assertions(out, &http.expected_response);
326
327 render_elixir_header_assertions(out, &http.expected_response);
329
330 let _ = writeln!(out, " end");
331 let _ = writeln!(out, " end");
332}
333
334fn render_elixir_http_request(out: &mut String, req: &HttpRequest, fixture_id: &str) {
336 let method = req.method.to_lowercase();
337
338 let mut opts: Vec<String> = Vec::new();
339
340 if let Some(body) = &req.body {
341 let elixir_val = json_to_elixir(body);
342 opts.push(format!("json: {elixir_val}"));
343 }
344
345 if !req.headers.is_empty() {
346 let header_pairs: Vec<String> = req
347 .headers
348 .iter()
349 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
350 .collect();
351 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
352 }
353
354 if !req.cookies.is_empty() {
355 let cookie_str = req
356 .cookies
357 .iter()
358 .map(|(k, v)| format!("{}={}", k, v))
359 .collect::<Vec<_>>()
360 .join("; ");
361 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
362 }
363
364 if !req.query_params.is_empty() {
365 let pairs: Vec<String> = req
366 .query_params
367 .iter()
368 .map(|(k, v)| {
369 let val_str = match v {
370 serde_json::Value::String(s) => s.clone(),
371 other => other.to_string(),
372 };
373 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
374 })
375 .collect();
376 opts.push(format!("params: [{}]", pairs.join(", ")));
377 }
378
379 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{}\"", escape_elixir(fixture_id));
381 if opts.is_empty() {
382 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
383 } else {
384 let opts_str = opts.join(", ");
385 let _ = writeln!(
386 out,
387 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
388 );
389 }
390}
391
392fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
394 if let Some(body) = &expected.body {
395 let elixir_val = json_to_elixir(body);
396 let _ = writeln!(out, " assert Jason.decode!(response.body) == {elixir_val}");
397 }
398 if let Some(partial) = &expected.body_partial {
399 if let Some(obj) = partial.as_object() {
400 let _ = writeln!(out, " decoded_body = Jason.decode!(response.body)");
401 for (key, val) in obj {
402 let key_lit = format!("\"{}\"", escape_elixir(key));
403 let elixir_val = json_to_elixir(val);
404 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
405 }
406 }
407 }
408 if let Some(errors) = &expected.validation_errors {
409 for err in errors {
410 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
411 let _ = writeln!(
412 out,
413 " assert String.contains?(Jason.encode!(response.body), {msg_lit})"
414 );
415 }
416 }
417}
418
419fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
426 for (name, value) in &expected.headers {
427 let header_key = name.to_lowercase();
428 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
429 let get_header_expr =
431 format!("Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: v end)");
432 match value.as_str() {
433 "<<present>>" => {
434 let _ = writeln!(out, " assert {get_header_expr} != nil");
435 }
436 "<<absent>>" => {
437 let _ = writeln!(out, " assert {get_header_expr} == nil");
438 }
439 "<<uuid>>" => {
440 let _ = writeln!(
441 out,
442 " header_val_{} = {get_header_expr}",
443 sanitize_ident(&header_key)
444 );
445 let _ = writeln!(
446 out,
447 " 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_{}))",
448 sanitize_ident(&header_key)
449 );
450 }
451 literal => {
452 let val_lit = format!("\"{}\"", escape_elixir(literal));
453 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
454 }
455 }
456 }
457}
458
459#[allow(clippy::too_many_arguments)]
464fn render_test_case(
465 out: &mut String,
466 fixture: &Fixture,
467 e2e_config: &E2eConfig,
468 default_module_path: &str,
469 default_function_name: &str,
470 default_result_var: &str,
471 args: &[crate::config::ArgMapping],
472 field_resolver: &FieldResolver,
473 options_type: Option<&str>,
474 options_default_fn: Option<&str>,
475 enum_fields: &HashMap<String, String>,
476 handle_struct_type: Option<&str>,
477 handle_atom_list_fields: &std::collections::HashSet<String>,
478) {
479 let test_name = sanitize_ident(&fixture.id);
480 let description = fixture.description.replace('"', "\\\"");
481
482 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
484 let lang = "elixir";
485 let call_overrides = call_config.overrides.get(lang);
486
487 let (module_path, function_name, result_var) = if fixture.call.is_some() {
490 let raw_module = call_overrides
491 .and_then(|o| o.module.as_ref())
492 .cloned()
493 .unwrap_or_else(|| call_config.module.clone());
494 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
495 {
496 raw_module.clone()
497 } else {
498 elixir_module_name(&raw_module)
499 };
500 let base_fn = call_overrides
501 .and_then(|o| o.function.as_ref())
502 .cloned()
503 .unwrap_or_else(|| call_config.function.clone());
504 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
505 format!("{base_fn}_async")
506 } else {
507 base_fn
508 };
509 (resolved_module, resolved_fn, call_config.result_var.clone())
510 } else {
511 (
512 default_module_path.to_string(),
513 default_function_name.to_string(),
514 default_result_var.to_string(),
515 )
516 };
517
518 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
519
520 let (mut setup_lines, args_str) = build_args_and_setup(
521 &fixture.input,
522 args,
523 &module_path,
524 options_type,
525 options_default_fn,
526 enum_fields,
527 &fixture.id,
528 handle_struct_type,
529 handle_atom_list_fields,
530 );
531
532 let final_args = if let Some(visitor_spec) = &fixture.visitor {
535 let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
536 format!("{args_str}, {visitor_var}")
537 } else {
538 args_str
539 };
540
541 let _ = writeln!(out, " describe \"{test_name}\" do");
542 let _ = writeln!(out, " test \"{description}\" do");
543
544 for line in &setup_lines {
545 let _ = writeln!(out, " {line}");
546 }
547
548 if expects_error {
549 let _ = writeln!(
550 out,
551 " assert {{:error, _}} = {module_path}.{function_name}({final_args})"
552 );
553 let _ = writeln!(out, " end");
554 let _ = writeln!(out, " end");
555 return;
556 }
557
558 let _ = writeln!(
559 out,
560 " {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
561 );
562
563 for assertion in &fixture.assertions {
564 render_assertion(out, assertion, &result_var, field_resolver, &module_path);
565 }
566
567 let _ = writeln!(out, " end");
568 let _ = writeln!(out, " end");
569}
570
571#[allow(clippy::too_many_arguments)]
575fn build_args_and_setup(
576 input: &serde_json::Value,
577 args: &[crate::config::ArgMapping],
578 module_path: &str,
579 options_type: Option<&str>,
580 options_default_fn: Option<&str>,
581 enum_fields: &HashMap<String, String>,
582 fixture_id: &str,
583 _handle_struct_type: Option<&str>,
584 _handle_atom_list_fields: &std::collections::HashSet<String>,
585) -> (Vec<String>, String) {
586 if args.is_empty() {
587 return (Vec::new(), json_to_elixir(input));
588 }
589
590 let mut setup_lines: Vec<String> = Vec::new();
591 let mut parts: Vec<String> = Vec::new();
592
593 for arg in args {
594 if arg.arg_type == "mock_url" {
595 setup_lines.push(format!(
596 "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
597 arg.name,
598 ));
599 parts.push(arg.name.clone());
600 continue;
601 }
602
603 if arg.arg_type == "handle" {
604 let constructor_name = format!("create_{}", arg.name.to_snake_case());
608 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
609 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
610 let name = &arg.name;
611 if config_value.is_null()
612 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
613 {
614 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
615 } else {
616 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
619 let escaped = escape_elixir(&json_str);
620 setup_lines.push(format!("{name}_config = \"{escaped}\""));
621 setup_lines.push(format!(
622 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
623 ));
624 }
625 parts.push(arg.name.clone());
626 continue;
627 }
628
629 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
630 let val = input.get(field);
631 match val {
632 None | Some(serde_json::Value::Null) if arg.optional => {
633 continue;
635 }
636 None | Some(serde_json::Value::Null) => {
637 let default_val = match arg.arg_type.as_str() {
639 "string" => "\"\"".to_string(),
640 "int" | "integer" => "0".to_string(),
641 "float" | "number" => "0.0".to_string(),
642 "bool" | "boolean" => "false".to_string(),
643 _ => "nil".to_string(),
644 };
645 parts.push(default_val);
646 }
647 Some(v) => {
648 if arg.arg_type == "json_object" && !v.is_null() {
650 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
651 (options_type, options_default_fn, v.as_object())
652 {
653 let options_var = "options";
655 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
656
657 for (k, vv) in obj.iter() {
659 let snake_key = k.to_snake_case();
660 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
661 if let Some(s) = vv.as_str() {
662 let snake_val = s.to_snake_case();
663 format!(":{snake_val}")
665 } else {
666 json_to_elixir(vv)
667 }
668 } else {
669 json_to_elixir(vv)
670 };
671 setup_lines.push(format!(
672 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
673 ));
674 }
675
676 parts.push(options_var.to_string());
678 continue;
679 }
680 }
681 parts.push(json_to_elixir(v));
682 }
683 }
684 }
685
686 (setup_lines, parts.join(", "))
687}
688
689fn is_numeric_expr(field_expr: &str) -> bool {
692 field_expr.starts_with("length(")
693}
694
695fn render_assertion(
696 out: &mut String,
697 assertion: &Assertion,
698 result_var: &str,
699 field_resolver: &FieldResolver,
700 module_path: &str,
701) {
702 if let Some(f) = &assertion.field {
704 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
705 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
706 return;
707 }
708 }
709
710 let field_expr = match &assertion.field {
711 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
712 _ => result_var.to_string(),
713 };
714
715 let is_numeric = is_numeric_expr(&field_expr);
718 let trimmed_field_expr = if is_numeric {
719 field_expr.clone()
720 } else {
721 format!("String.trim({field_expr})")
722 };
723
724 match assertion.assertion_type.as_str() {
725 "equals" => {
726 if let Some(expected) = &assertion.value {
727 let elixir_val = json_to_elixir(expected);
728 let is_string_expected = expected.is_string();
730 if is_string_expected && !is_numeric {
731 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
732 } else {
733 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
734 }
735 }
736 }
737 "contains" => {
738 if let Some(expected) = &assertion.value {
739 let elixir_val = json_to_elixir(expected);
740 let _ = writeln!(
742 out,
743 " assert String.contains?(to_string({field_expr}), {elixir_val})"
744 );
745 }
746 }
747 "contains_all" => {
748 if let Some(values) = &assertion.values {
749 for val in values {
750 let elixir_val = json_to_elixir(val);
751 let _ = writeln!(
752 out,
753 " assert String.contains?(to_string({field_expr}), {elixir_val})"
754 );
755 }
756 }
757 }
758 "not_contains" => {
759 if let Some(expected) = &assertion.value {
760 let elixir_val = json_to_elixir(expected);
761 let _ = writeln!(
762 out,
763 " refute String.contains?(to_string({field_expr}), {elixir_val})"
764 );
765 }
766 }
767 "not_empty" => {
768 let _ = writeln!(out, " assert {field_expr} != \"\"");
769 }
770 "is_empty" => {
771 if is_numeric {
772 let _ = writeln!(out, " assert {field_expr} == 0");
774 } else {
775 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
777 }
778 }
779 "contains_any" => {
780 if let Some(values) = &assertion.values {
781 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
782 let list_str = items.join(", ");
783 let _ = writeln!(
784 out,
785 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
786 );
787 }
788 }
789 "greater_than" => {
790 if let Some(val) = &assertion.value {
791 let elixir_val = json_to_elixir(val);
792 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
793 }
794 }
795 "less_than" => {
796 if let Some(val) = &assertion.value {
797 let elixir_val = json_to_elixir(val);
798 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
799 }
800 }
801 "greater_than_or_equal" => {
802 if let Some(val) = &assertion.value {
803 let elixir_val = json_to_elixir(val);
804 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
805 }
806 }
807 "less_than_or_equal" => {
808 if let Some(val) = &assertion.value {
809 let elixir_val = json_to_elixir(val);
810 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
811 }
812 }
813 "starts_with" => {
814 if let Some(expected) = &assertion.value {
815 let elixir_val = json_to_elixir(expected);
816 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
817 }
818 }
819 "ends_with" => {
820 if let Some(expected) = &assertion.value {
821 let elixir_val = json_to_elixir(expected);
822 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
823 }
824 }
825 "min_length" => {
826 if let Some(val) = &assertion.value {
827 if let Some(n) = val.as_u64() {
828 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
829 }
830 }
831 }
832 "max_length" => {
833 if let Some(val) = &assertion.value {
834 if let Some(n) = val.as_u64() {
835 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
836 }
837 }
838 }
839 "count_min" => {
840 if let Some(val) = &assertion.value {
841 if let Some(n) = val.as_u64() {
842 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
843 }
844 }
845 }
846 "count_equals" => {
847 if let Some(val) = &assertion.value {
848 if let Some(n) = val.as_u64() {
849 let _ = writeln!(out, " assert length({field_expr}) == {n}");
850 }
851 }
852 }
853 "is_true" => {
854 let _ = writeln!(out, " assert {field_expr} == true");
855 }
856 "is_false" => {
857 let _ = writeln!(out, " assert {field_expr} == false");
858 }
859 "method_result" => {
860 if let Some(method_name) = &assertion.method {
861 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
862 let check = assertion.check.as_deref().unwrap_or("is_true");
863 match check {
864 "equals" => {
865 if let Some(val) = &assertion.value {
866 let elixir_val = json_to_elixir(val);
867 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
868 }
869 }
870 "is_true" => {
871 let _ = writeln!(out, " assert {call_expr} == true");
872 }
873 "is_false" => {
874 let _ = writeln!(out, " assert {call_expr} == false");
875 }
876 "greater_than_or_equal" => {
877 if let Some(val) = &assertion.value {
878 let n = val.as_u64().unwrap_or(0);
879 let _ = writeln!(out, " assert {call_expr} >= {n}");
880 }
881 }
882 "count_min" => {
883 if let Some(val) = &assertion.value {
884 let n = val.as_u64().unwrap_or(0);
885 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
886 }
887 }
888 "contains" => {
889 if let Some(val) = &assertion.value {
890 let elixir_val = json_to_elixir(val);
891 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
892 }
893 }
894 "is_error" => {
895 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
896 }
897 other_check => {
898 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
899 }
900 }
901 } else {
902 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
903 }
904 }
905 "matches_regex" => {
906 if let Some(expected) = &assertion.value {
907 let elixir_val = json_to_elixir(expected);
908 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
909 }
910 }
911 "not_error" => {
912 }
914 "error" => {
915 }
917 other => {
918 panic!("Elixir e2e generator: unsupported assertion type: {other}");
919 }
920 }
921}
922
923fn build_elixir_method_call(
926 result_var: &str,
927 method_name: &str,
928 args: Option<&serde_json::Value>,
929 module_path: &str,
930) -> String {
931 match method_name {
932 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
933 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
934 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
935 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
936 "contains_node_type" => {
937 let node_type = args
938 .and_then(|a| a.get("node_type"))
939 .and_then(|v| v.as_str())
940 .unwrap_or("");
941 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
942 }
943 "find_nodes_by_type" => {
944 let node_type = args
945 .and_then(|a| a.get("node_type"))
946 .and_then(|v| v.as_str())
947 .unwrap_or("");
948 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
949 }
950 "run_query" => {
951 let query_source = args
952 .and_then(|a| a.get("query_source"))
953 .and_then(|v| v.as_str())
954 .unwrap_or("");
955 let language = args
956 .and_then(|a| a.get("language"))
957 .and_then(|v| v.as_str())
958 .unwrap_or("");
959 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
960 }
961 _ => format!("{module_path}.{method_name}({result_var})"),
962 }
963}
964
965fn elixir_module_name(category: &str) -> String {
967 use heck::ToUpperCamelCase;
968 category.to_upper_camel_case()
969}
970
971fn json_to_elixir(value: &serde_json::Value) -> String {
973 match value {
974 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
975 serde_json::Value::Bool(true) => "true".to_string(),
976 serde_json::Value::Bool(false) => "false".to_string(),
977 serde_json::Value::Number(n) => n.to_string(),
978 serde_json::Value::Null => "nil".to_string(),
979 serde_json::Value::Array(arr) => {
980 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
981 format!("[{}]", items.join(", "))
982 }
983 serde_json::Value::Object(map) => {
984 let entries: Vec<String> = map
985 .iter()
986 .map(|(k, v)| format!("\"{}\" => {}", k.to_snake_case(), json_to_elixir(v)))
987 .collect();
988 format!("%{{{}}}", entries.join(", "))
989 }
990 }
991}
992
993fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
995 use std::fmt::Write as FmtWrite;
996 let mut visitor_obj = String::new();
997 let _ = writeln!(visitor_obj, "%{{");
998 for (method_name, action) in &visitor_spec.callbacks {
999 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1000 }
1001 let _ = writeln!(visitor_obj, " }}");
1002
1003 setup_lines.push(format!("visitor = {visitor_obj}"));
1004 "visitor".to_string()
1005}
1006
1007fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1009 use std::fmt::Write as FmtWrite;
1010
1011 let handle_method = format!("handle_{}", &method_name[6..]); let params = match method_name {
1014 "visit_link" => "_ctx, _href, _text, _title",
1015 "visit_image" => "_ctx, _src, _alt, _title",
1016 "visit_heading" => "_ctx, _level, text, _id",
1017 "visit_code_block" => "_ctx, _lang, _code",
1018 "visit_code_inline"
1019 | "visit_strong"
1020 | "visit_emphasis"
1021 | "visit_strikethrough"
1022 | "visit_underline"
1023 | "visit_subscript"
1024 | "visit_superscript"
1025 | "visit_mark"
1026 | "visit_button"
1027 | "visit_summary"
1028 | "visit_figcaption"
1029 | "visit_definition_term"
1030 | "visit_definition_description" => "_ctx, _text",
1031 "visit_text" => "_ctx, _text",
1032 "visit_list_item" => "_ctx, _ordered, _marker, _text",
1033 "visit_blockquote" => "_ctx, _content, _depth",
1034 "visit_table_row" => "_ctx, _cells, _is_header",
1035 "visit_custom_element" => "_ctx, _tag_name, _html",
1036 "visit_form" => "_ctx, _action_url, _method",
1037 "visit_input" => "_ctx, _input_type, _name, _value",
1038 "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1039 "visit_details" => "_ctx, _is_open",
1040 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "_ctx, _output",
1041 "visit_list_start" => "_ctx, _ordered",
1042 "visit_list_end" => "_ctx, _ordered, _output",
1043 _ => "_ctx",
1044 };
1045
1046 let _ = writeln!(out, " :{handle_method} => fn({params}) ->");
1047 match action {
1048 CallbackAction::Skip => {
1049 let _ = writeln!(out, " :skip");
1050 }
1051 CallbackAction::Continue => {
1052 let _ = writeln!(out, " :continue");
1053 }
1054 CallbackAction::PreserveHtml => {
1055 let _ = writeln!(out, " :preserve_html");
1056 }
1057 CallbackAction::Custom { output } => {
1058 let escaped = escape_elixir(output);
1059 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1060 }
1061 CallbackAction::CustomTemplate { template } => {
1062 let escaped = escape_elixir(template);
1064 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1065 }
1066 }
1067 let _ = writeln!(out, " end,");
1068}