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: "ExUnit.start()\n".to_string(),
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_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 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 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
277fn 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 render_elixir_http_request(out, &http.request);
293
294 let status = http.expected_response.status_code;
296 let _ = writeln!(out, " assert response.status == {status}");
297
298 render_elixir_body_assertions(out, &http.expected_response);
300
301 render_elixir_header_assertions(out, &http.expected_response);
303
304 let _ = writeln!(out, " end");
305 let _ = writeln!(out, " end");
306}
307
308fn 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
365fn 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
392fn 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 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#[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 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 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 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#[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 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 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 continue;
608 }
609 None | Some(serde_json::Value::Null) => {
610 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 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 let options_var = "options";
628 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
629
630 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 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 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
662fn 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 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 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 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 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 let _ = writeln!(out, " assert {field_expr} == 0");
747 } else {
748 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 }
887 "error" => {
888 }
890 other => {
891 panic!("Elixir e2e generator: unsupported assertion type: {other}");
892 }
893 }
894}
895
896fn 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
938fn elixir_module_name(category: &str) -> String {
940 use heck::ToUpperCamelCase;
941 category.to_upper_camel_case()
942}
943
944fn 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
966fn 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
980fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
982 use std::fmt::Write as FmtWrite;
983
984 let handle_method = format!("handle_{}", &method_name[6..]); 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 let escaped = escape_elixir(template);
1037 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1038 }
1039 }
1040 let _ = writeln!(out, " end,");
1041}