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
304fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
310 let test_name = sanitize_ident(&fixture.id);
311 let description = fixture.description.replace('"', "\\\"");
312 let method = http.request.method.to_uppercase();
313 let path = &http.request.path;
314 let fixture_id = &fixture.id;
315
316 let _ = writeln!(out, " describe \"{test_name}\" do");
317 let _ = writeln!(out, " test \"{method} {path} - {description}\" do");
318
319 render_elixir_http_request(out, &http.request, fixture_id);
321
322 let status = http.expected_response.status_code;
324 let _ = writeln!(out, " assert response.status == {status}");
325
326 render_elixir_body_assertions(out, &http.expected_response);
328
329 render_elixir_header_assertions(out, &http.expected_response);
331
332 let _ = writeln!(out, " end");
333 let _ = writeln!(out, " end");
334}
335
336const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
339
340fn render_elixir_http_request(out: &mut String, req: &HttpRequest, fixture_id: &str) {
342 let method = req.method.to_lowercase();
343
344 let mut opts: Vec<String> = Vec::new();
345
346 if let Some(body) = &req.body {
347 let elixir_val = json_to_elixir(body);
348 opts.push(format!("json: {elixir_val}"));
349 }
350
351 if !req.headers.is_empty() {
352 let header_pairs: Vec<String> = req
353 .headers
354 .iter()
355 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
356 .collect();
357 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
358 }
359
360 if !req.cookies.is_empty() {
361 let cookie_str = req
362 .cookies
363 .iter()
364 .map(|(k, v)| format!("{}={}", k, v))
365 .collect::<Vec<_>>()
366 .join("; ");
367 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
368 }
369
370 if !req.query_params.is_empty() {
371 let pairs: Vec<String> = req
372 .query_params
373 .iter()
374 .map(|(k, v)| {
375 let val_str = match v {
376 serde_json::Value::String(s) => s.clone(),
377 other => other.to_string(),
378 };
379 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
380 })
381 .collect();
382 opts.push(format!("params: [{}]", pairs.join(", ")));
383 }
384
385 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{}\"", escape_elixir(fixture_id));
387
388 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
391 if opts.is_empty() {
392 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
393 } else {
394 let opts_str = opts.join(", ");
395 let _ = writeln!(
396 out,
397 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
398 );
399 }
400 } else {
401 opts.insert(0, format!("method: :{method}"));
403 opts.insert(1, format!("url: {url_expr}"));
404 let opts_str = opts.join(", ");
405 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
406 }
407}
408
409fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
411 if let Some(body) = &expected.body {
412 let elixir_val = json_to_elixir(body);
413 let _ = writeln!(out, " assert response.body == {elixir_val}");
415 }
416 if let Some(partial) = &expected.body_partial {
417 if let Some(obj) = partial.as_object() {
418 let _ = writeln!(out, " decoded_body = response.body");
420 for (key, val) in obj {
421 let key_lit = format!("\"{}\"", escape_elixir(key));
422 let elixir_val = json_to_elixir(val);
423 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
424 }
425 }
426 }
427 if let Some(errors) = &expected.validation_errors {
428 for err in errors {
429 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
430 let _ = writeln!(
431 out,
432 " assert String.contains?(Jason.encode!(response.body), {msg_lit})"
433 );
434 }
435 }
436}
437
438fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
445 for (name, value) in &expected.headers {
446 let header_key = name.to_lowercase();
447 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
448 let get_header_expr = format!(
450 "Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
451 );
452 match value.as_str() {
453 "<<present>>" => {
454 let _ = writeln!(out, " assert {get_header_expr} != nil");
455 }
456 "<<absent>>" => {
457 let _ = writeln!(out, " assert {get_header_expr} == nil");
458 }
459 "<<uuid>>" => {
460 let _ = writeln!(
461 out,
462 " header_val_{} = {get_header_expr}",
463 sanitize_ident(&header_key)
464 );
465 let _ = writeln!(
466 out,
467 " 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_{}))",
468 sanitize_ident(&header_key)
469 );
470 }
471 literal => {
472 let val_lit = format!("\"{}\"", escape_elixir(literal));
473 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
474 }
475 }
476 }
477}
478
479#[allow(clippy::too_many_arguments)]
484fn render_test_case(
485 out: &mut String,
486 fixture: &Fixture,
487 e2e_config: &E2eConfig,
488 default_module_path: &str,
489 default_function_name: &str,
490 default_result_var: &str,
491 args: &[crate::config::ArgMapping],
492 field_resolver: &FieldResolver,
493 options_type: Option<&str>,
494 options_default_fn: Option<&str>,
495 enum_fields: &HashMap<String, String>,
496 handle_struct_type: Option<&str>,
497 handle_atom_list_fields: &std::collections::HashSet<String>,
498) {
499 let test_name = sanitize_ident(&fixture.id);
500 let description = fixture.description.replace('"', "\\\"");
501
502 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
504 let lang = "elixir";
505 let call_overrides = call_config.overrides.get(lang);
506
507 let (module_path, function_name, result_var) = if fixture.call.is_some() {
510 let raw_module = call_overrides
511 .and_then(|o| o.module.as_ref())
512 .cloned()
513 .unwrap_or_else(|| call_config.module.clone());
514 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
515 {
516 raw_module.clone()
517 } else {
518 elixir_module_name(&raw_module)
519 };
520 let base_fn = call_overrides
521 .and_then(|o| o.function.as_ref())
522 .cloned()
523 .unwrap_or_else(|| call_config.function.clone());
524 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
525 format!("{base_fn}_async")
526 } else {
527 base_fn
528 };
529 (resolved_module, resolved_fn, call_config.result_var.clone())
530 } else {
531 (
532 default_module_path.to_string(),
533 default_function_name.to_string(),
534 default_result_var.to_string(),
535 )
536 };
537
538 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
539
540 let (mut setup_lines, args_str) = build_args_and_setup(
541 &fixture.input,
542 args,
543 &module_path,
544 options_type,
545 options_default_fn,
546 enum_fields,
547 &fixture.id,
548 handle_struct_type,
549 handle_atom_list_fields,
550 );
551
552 let final_args = if let Some(visitor_spec) = &fixture.visitor {
555 let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
556 format!("{args_str}, {visitor_var}")
557 } else {
558 args_str
559 };
560
561 let _ = writeln!(out, " describe \"{test_name}\" do");
562 let _ = writeln!(out, " test \"{description}\" do");
563
564 for line in &setup_lines {
565 let _ = writeln!(out, " {line}");
566 }
567
568 if expects_error {
569 let _ = writeln!(
570 out,
571 " assert {{:error, _}} = {module_path}.{function_name}({final_args})"
572 );
573 let _ = writeln!(out, " end");
574 let _ = writeln!(out, " end");
575 return;
576 }
577
578 let _ = writeln!(
579 out,
580 " {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
581 );
582
583 for assertion in &fixture.assertions {
584 render_assertion(out, assertion, &result_var, field_resolver, &module_path);
585 }
586
587 let _ = writeln!(out, " end");
588 let _ = writeln!(out, " end");
589}
590
591#[allow(clippy::too_many_arguments)]
595fn build_args_and_setup(
596 input: &serde_json::Value,
597 args: &[crate::config::ArgMapping],
598 module_path: &str,
599 options_type: Option<&str>,
600 options_default_fn: Option<&str>,
601 enum_fields: &HashMap<String, String>,
602 fixture_id: &str,
603 _handle_struct_type: Option<&str>,
604 _handle_atom_list_fields: &std::collections::HashSet<String>,
605) -> (Vec<String>, String) {
606 if args.is_empty() {
607 return (Vec::new(), json_to_elixir(input));
608 }
609
610 let mut setup_lines: Vec<String> = Vec::new();
611 let mut parts: Vec<String> = Vec::new();
612
613 for arg in args {
614 if arg.arg_type == "mock_url" {
615 setup_lines.push(format!(
616 "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
617 arg.name,
618 ));
619 parts.push(arg.name.clone());
620 continue;
621 }
622
623 if arg.arg_type == "handle" {
624 let constructor_name = format!("create_{}", arg.name.to_snake_case());
628 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
629 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
630 let name = &arg.name;
631 if config_value.is_null()
632 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
633 {
634 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
635 } else {
636 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
639 let escaped = escape_elixir(&json_str);
640 setup_lines.push(format!("{name}_config = \"{escaped}\""));
641 setup_lines.push(format!(
642 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
643 ));
644 }
645 parts.push(arg.name.clone());
646 continue;
647 }
648
649 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
650 let val = input.get(field);
651 match val {
652 None | Some(serde_json::Value::Null) if arg.optional => {
653 continue;
655 }
656 None | Some(serde_json::Value::Null) => {
657 let default_val = match arg.arg_type.as_str() {
659 "string" => "\"\"".to_string(),
660 "int" | "integer" => "0".to_string(),
661 "float" | "number" => "0.0".to_string(),
662 "bool" | "boolean" => "false".to_string(),
663 _ => "nil".to_string(),
664 };
665 parts.push(default_val);
666 }
667 Some(v) => {
668 if arg.arg_type == "json_object" && !v.is_null() {
670 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
671 (options_type, options_default_fn, v.as_object())
672 {
673 let options_var = "options";
675 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
676
677 for (k, vv) in obj.iter() {
679 let snake_key = k.to_snake_case();
680 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
681 if let Some(s) = vv.as_str() {
682 let snake_val = s.to_snake_case();
683 format!(":{snake_val}")
685 } else {
686 json_to_elixir(vv)
687 }
688 } else {
689 json_to_elixir(vv)
690 };
691 setup_lines.push(format!(
692 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
693 ));
694 }
695
696 parts.push(options_var.to_string());
698 continue;
699 }
700 }
701 parts.push(json_to_elixir(v));
702 }
703 }
704 }
705
706 (setup_lines, parts.join(", "))
707}
708
709fn is_numeric_expr(field_expr: &str) -> bool {
712 field_expr.starts_with("length(")
713}
714
715fn render_assertion(
716 out: &mut String,
717 assertion: &Assertion,
718 result_var: &str,
719 field_resolver: &FieldResolver,
720 module_path: &str,
721) {
722 if let Some(f) = &assertion.field {
724 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
725 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
726 return;
727 }
728 }
729
730 let field_expr = match &assertion.field {
731 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
732 _ => result_var.to_string(),
733 };
734
735 let is_numeric = is_numeric_expr(&field_expr);
738 let trimmed_field_expr = if is_numeric {
739 field_expr.clone()
740 } else {
741 format!("String.trim({field_expr})")
742 };
743
744 match assertion.assertion_type.as_str() {
745 "equals" => {
746 if let Some(expected) = &assertion.value {
747 let elixir_val = json_to_elixir(expected);
748 let is_string_expected = expected.is_string();
750 if is_string_expected && !is_numeric {
751 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
752 } else {
753 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
754 }
755 }
756 }
757 "contains" => {
758 if let Some(expected) = &assertion.value {
759 let elixir_val = json_to_elixir(expected);
760 let _ = writeln!(
762 out,
763 " assert String.contains?(to_string({field_expr}), {elixir_val})"
764 );
765 }
766 }
767 "contains_all" => {
768 if let Some(values) = &assertion.values {
769 for val in values {
770 let elixir_val = json_to_elixir(val);
771 let _ = writeln!(
772 out,
773 " assert String.contains?(to_string({field_expr}), {elixir_val})"
774 );
775 }
776 }
777 }
778 "not_contains" => {
779 if let Some(expected) = &assertion.value {
780 let elixir_val = json_to_elixir(expected);
781 let _ = writeln!(
782 out,
783 " refute String.contains?(to_string({field_expr}), {elixir_val})"
784 );
785 }
786 }
787 "not_empty" => {
788 let _ = writeln!(out, " assert {field_expr} != \"\"");
789 }
790 "is_empty" => {
791 if is_numeric {
792 let _ = writeln!(out, " assert {field_expr} == 0");
794 } else {
795 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
797 }
798 }
799 "contains_any" => {
800 if let Some(values) = &assertion.values {
801 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
802 let list_str = items.join(", ");
803 let _ = writeln!(
804 out,
805 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
806 );
807 }
808 }
809 "greater_than" => {
810 if let Some(val) = &assertion.value {
811 let elixir_val = json_to_elixir(val);
812 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
813 }
814 }
815 "less_than" => {
816 if let Some(val) = &assertion.value {
817 let elixir_val = json_to_elixir(val);
818 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
819 }
820 }
821 "greater_than_or_equal" => {
822 if let Some(val) = &assertion.value {
823 let elixir_val = json_to_elixir(val);
824 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
825 }
826 }
827 "less_than_or_equal" => {
828 if let Some(val) = &assertion.value {
829 let elixir_val = json_to_elixir(val);
830 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
831 }
832 }
833 "starts_with" => {
834 if let Some(expected) = &assertion.value {
835 let elixir_val = json_to_elixir(expected);
836 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
837 }
838 }
839 "ends_with" => {
840 if let Some(expected) = &assertion.value {
841 let elixir_val = json_to_elixir(expected);
842 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
843 }
844 }
845 "min_length" => {
846 if let Some(val) = &assertion.value {
847 if let Some(n) = val.as_u64() {
848 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
849 }
850 }
851 }
852 "max_length" => {
853 if let Some(val) = &assertion.value {
854 if let Some(n) = val.as_u64() {
855 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
856 }
857 }
858 }
859 "count_min" => {
860 if let Some(val) = &assertion.value {
861 if let Some(n) = val.as_u64() {
862 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
863 }
864 }
865 }
866 "count_equals" => {
867 if let Some(val) = &assertion.value {
868 if let Some(n) = val.as_u64() {
869 let _ = writeln!(out, " assert length({field_expr}) == {n}");
870 }
871 }
872 }
873 "is_true" => {
874 let _ = writeln!(out, " assert {field_expr} == true");
875 }
876 "is_false" => {
877 let _ = writeln!(out, " assert {field_expr} == false");
878 }
879 "method_result" => {
880 if let Some(method_name) = &assertion.method {
881 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
882 let check = assertion.check.as_deref().unwrap_or("is_true");
883 match check {
884 "equals" => {
885 if let Some(val) = &assertion.value {
886 let elixir_val = json_to_elixir(val);
887 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
888 }
889 }
890 "is_true" => {
891 let _ = writeln!(out, " assert {call_expr} == true");
892 }
893 "is_false" => {
894 let _ = writeln!(out, " assert {call_expr} == false");
895 }
896 "greater_than_or_equal" => {
897 if let Some(val) = &assertion.value {
898 let n = val.as_u64().unwrap_or(0);
899 let _ = writeln!(out, " assert {call_expr} >= {n}");
900 }
901 }
902 "count_min" => {
903 if let Some(val) = &assertion.value {
904 let n = val.as_u64().unwrap_or(0);
905 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
906 }
907 }
908 "contains" => {
909 if let Some(val) = &assertion.value {
910 let elixir_val = json_to_elixir(val);
911 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
912 }
913 }
914 "is_error" => {
915 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
916 }
917 other_check => {
918 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
919 }
920 }
921 } else {
922 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
923 }
924 }
925 "matches_regex" => {
926 if let Some(expected) = &assertion.value {
927 let elixir_val = json_to_elixir(expected);
928 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
929 }
930 }
931 "not_error" => {
932 }
934 "error" => {
935 }
937 other => {
938 panic!("Elixir e2e generator: unsupported assertion type: {other}");
939 }
940 }
941}
942
943fn build_elixir_method_call(
946 result_var: &str,
947 method_name: &str,
948 args: Option<&serde_json::Value>,
949 module_path: &str,
950) -> String {
951 match method_name {
952 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
953 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
954 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
955 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
956 "contains_node_type" => {
957 let node_type = args
958 .and_then(|a| a.get("node_type"))
959 .and_then(|v| v.as_str())
960 .unwrap_or("");
961 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
962 }
963 "find_nodes_by_type" => {
964 let node_type = args
965 .and_then(|a| a.get("node_type"))
966 .and_then(|v| v.as_str())
967 .unwrap_or("");
968 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
969 }
970 "run_query" => {
971 let query_source = args
972 .and_then(|a| a.get("query_source"))
973 .and_then(|v| v.as_str())
974 .unwrap_or("");
975 let language = args
976 .and_then(|a| a.get("language"))
977 .and_then(|v| v.as_str())
978 .unwrap_or("");
979 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
980 }
981 _ => format!("{module_path}.{method_name}({result_var})"),
982 }
983}
984
985fn elixir_module_name(category: &str) -> String {
987 use heck::ToUpperCamelCase;
988 category.to_upper_camel_case()
989}
990
991fn json_to_elixir(value: &serde_json::Value) -> String {
993 match value {
994 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
995 serde_json::Value::Bool(true) => "true".to_string(),
996 serde_json::Value::Bool(false) => "false".to_string(),
997 serde_json::Value::Number(n) => {
998 let s = n.to_string().replace("e+", "e");
1002 if s.contains('e') && !s.contains('.') {
1003 s.replacen('e', ".0e", 1)
1005 } else {
1006 s
1007 }
1008 }
1009 serde_json::Value::Null => "nil".to_string(),
1010 serde_json::Value::Array(arr) => {
1011 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1012 format!("[{}]", items.join(", "))
1013 }
1014 serde_json::Value::Object(map) => {
1015 let entries: Vec<String> = map
1016 .iter()
1017 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1018 .collect();
1019 format!("%{{{}}}", entries.join(", "))
1020 }
1021 }
1022}
1023
1024fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1026 use std::fmt::Write as FmtWrite;
1027 let mut visitor_obj = String::new();
1028 let _ = writeln!(visitor_obj, "%{{");
1029 for (method_name, action) in &visitor_spec.callbacks {
1030 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1031 }
1032 let _ = writeln!(visitor_obj, " }}");
1033
1034 setup_lines.push(format!("visitor = {visitor_obj}"));
1035 "visitor".to_string()
1036}
1037
1038fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1040 use std::fmt::Write as FmtWrite;
1041
1042 let handle_method = format!("handle_{}", &method_name[6..]); let params = match method_name {
1045 "visit_link" => "_ctx, _href, _text, _title",
1046 "visit_image" => "_ctx, _src, _alt, _title",
1047 "visit_heading" => "_ctx, _level, text, _id",
1048 "visit_code_block" => "_ctx, _lang, _code",
1049 "visit_code_inline"
1050 | "visit_strong"
1051 | "visit_emphasis"
1052 | "visit_strikethrough"
1053 | "visit_underline"
1054 | "visit_subscript"
1055 | "visit_superscript"
1056 | "visit_mark"
1057 | "visit_button"
1058 | "visit_summary"
1059 | "visit_figcaption"
1060 | "visit_definition_term"
1061 | "visit_definition_description" => "_ctx, _text",
1062 "visit_text" => "_ctx, _text",
1063 "visit_list_item" => "_ctx, _ordered, _marker, _text",
1064 "visit_blockquote" => "_ctx, _content, _depth",
1065 "visit_table_row" => "_ctx, _cells, _is_header",
1066 "visit_custom_element" => "_ctx, _tag_name, _html",
1067 "visit_form" => "_ctx, _action_url, _method",
1068 "visit_input" => "_ctx, _input_type, _name, _value",
1069 "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1070 "visit_details" => "_ctx, _is_open",
1071 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "_ctx, _output",
1072 "visit_list_start" => "_ctx, _ordered",
1073 "visit_list_end" => "_ctx, _ordered, _output",
1074 _ => "_ctx",
1075 };
1076
1077 let _ = writeln!(out, " :{handle_method} => fn({params}) ->");
1078 match action {
1079 CallbackAction::Skip => {
1080 let _ = writeln!(out, " :skip");
1081 }
1082 CallbackAction::Continue => {
1083 let _ = writeln!(out, " :continue");
1084 }
1085 CallbackAction::PreserveHtml => {
1086 let _ = writeln!(out, " :preserve_html");
1087 }
1088 CallbackAction::Custom { output } => {
1089 let escaped = escape_elixir(output);
1090 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1091 }
1092 CallbackAction::CustomTemplate { template } => {
1093 let escaped = escape_elixir(template);
1095 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1096 }
1097 }
1098 let _ = writeln!(out, " end,");
1099}