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