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 has_http_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| f.is_http_test()));
75 let has_nif_tests = groups.iter().any(|g| g.fixtures.iter().any(|f| !f.is_http_test()));
76
77 let pkg_ref = e2e_config.resolve_package(lang);
79 let pkg_path = if has_nif_tests {
80 pkg_ref.as_ref().and_then(|p| p.path.as_deref()).unwrap_or("")
81 } else {
82 ""
83 };
84
85 let pkg_atom = alef_config.crate_config.name.replace('-', "_");
89 files.push(GeneratedFile {
90 path: output_base.join("mix.exs"),
91 content: render_mix_exs(&pkg_atom, pkg_path, e2e_config.dep_mode, has_http_tests, has_nif_tests),
92 generated_header: false,
93 });
94
95 files.push(GeneratedFile {
97 path: output_base.join("lib").join("e2e_elixir.ex"),
98 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
99 generated_header: false,
100 });
101
102 files.push(GeneratedFile {
104 path: output_base.join("test").join("test_helper.exs"),
105 content: render_test_helper(has_http_tests),
106 generated_header: false,
107 });
108
109 for group in groups {
111 let active: Vec<&Fixture> = group
112 .fixtures
113 .iter()
114 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
115 .collect();
116
117 if active.is_empty() {
118 continue;
119 }
120
121 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
122 let field_resolver = FieldResolver::new(
123 &e2e_config.fields,
124 &e2e_config.fields_optional,
125 &e2e_config.result_fields,
126 &e2e_config.fields_array,
127 );
128 let content = render_test_file(
129 &group.category,
130 &active,
131 e2e_config,
132 &module_path,
133 &function_name,
134 result_var,
135 &e2e_config.call.args,
136 &field_resolver,
137 options_type.as_deref(),
138 options_default_fn.as_deref(),
139 enum_fields,
140 handle_struct_type.as_deref(),
141 handle_atom_list_fields,
142 );
143 files.push(GeneratedFile {
144 path: output_base.join("test").join(filename),
145 content,
146 generated_header: true,
147 });
148 }
149
150 Ok(files)
151 }
152
153 fn language_name(&self) -> &'static str {
154 "elixir"
155 }
156}
157
158fn render_test_helper(has_http_tests: bool) -> String {
159 if has_http_tests {
160 r#"ExUnit.start()
161
162# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
163mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
164fixtures_dir = Path.expand("../../../fixtures", __DIR__)
165
166if File.exists?(mock_server_bin) do
167 port = Port.open({:spawn_executable, mock_server_bin}, [
168 :binary,
169 :line,
170 args: [fixtures_dir]
171 ])
172 receive do
173 {^port, {:data, {:eol, "MOCK_SERVER_URL=" <> url}}} ->
174 System.put_env("MOCK_SERVER_URL", url)
175 after
176 30_000 ->
177 raise "mock-server startup timeout"
178 end
179end
180"#
181 .to_string()
182 } else {
183 "ExUnit.start()\n".to_string()
184 }
185}
186
187fn render_mix_exs(
188 pkg_name: &str,
189 pkg_path: &str,
190 dep_mode: crate::config::DependencyMode,
191 has_http_tests: bool,
192 has_nif_tests: bool,
193) -> String {
194 let mut out = String::new();
195 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
196 let _ = writeln!(out, " use Mix.Project");
197 let _ = writeln!(out);
198 let _ = writeln!(out, " def project do");
199 let _ = writeln!(out, " [");
200 let _ = writeln!(out, " app: :e2e_elixir,");
201 let _ = writeln!(out, " version: \"0.1.0\",");
202 let _ = writeln!(out, " elixir: \"~> 1.14\",");
203 let _ = writeln!(out, " deps: deps()");
204 let _ = writeln!(out, " ]");
205 let _ = writeln!(out, " end");
206 let _ = writeln!(out);
207 let _ = writeln!(out, " defp deps do");
208 let _ = writeln!(out, " [");
209
210 let mut deps: Vec<String> = Vec::new();
212
213 if has_nif_tests && !pkg_path.is_empty() {
215 let pkg_atom = pkg_name;
216 let nif_dep = match dep_mode {
217 crate::config::DependencyMode::Local => {
218 format!(" {{:{pkg_atom}, path: \"{pkg_path}\"}}")
219 }
220 crate::config::DependencyMode::Registry => {
221 format!(" {{:{pkg_atom}, \"{pkg_path}\"}}")
223 }
224 };
225 deps.push(nif_dep);
226 deps.push(format!(
228 " {{:rustler, \"{rustler}\", optional: true, runtime: false}}",
229 rustler = tv::hex::RUSTLER
230 ));
231 }
232
233 if has_http_tests {
235 deps.push(format!(" {{:req, \"{req}\"}}", req = tv::hex::REQ));
236 deps.push(format!(" {{:jason, \"{jason}\"}}", jason = tv::hex::JASON));
237 }
238
239 let _ = writeln!(out, "{}", deps.join(",\n"));
240 let _ = writeln!(out, " ]");
241 let _ = writeln!(out, " end");
242 let _ = writeln!(out, "end");
243 out
244}
245
246#[allow(clippy::too_many_arguments)]
247fn render_test_file(
248 category: &str,
249 fixtures: &[&Fixture],
250 e2e_config: &E2eConfig,
251 module_path: &str,
252 function_name: &str,
253 result_var: &str,
254 args: &[crate::config::ArgMapping],
255 field_resolver: &FieldResolver,
256 options_type: Option<&str>,
257 options_default_fn: Option<&str>,
258 enum_fields: &HashMap<String, String>,
259 handle_struct_type: Option<&str>,
260 handle_atom_list_fields: &std::collections::HashSet<String>,
261) -> String {
262 let mut out = String::new();
263 out.push_str(&hash::header(CommentStyle::Hash));
264 let _ = writeln!(out, "# E2e tests for category: {category}");
265 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
266
267 let has_http = fixtures.iter().any(|f| f.is_http_test());
269
270 let async_flag = if has_http { "true" } else { "false" };
273 let _ = writeln!(out, " use ExUnit.Case, async: {async_flag}");
274
275 if has_http {
276 let _ = writeln!(out);
277 let _ = writeln!(out, " defp mock_server_url do");
278 let _ = writeln!(
279 out,
280 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
281 );
282 let _ = writeln!(out, " end");
283 }
284
285 let _ = writeln!(out);
286
287 for (i, fixture) in fixtures.iter().enumerate() {
288 if let Some(http) = &fixture.http {
289 render_http_test_case(&mut out, fixture, http);
290 } else {
291 render_test_case(
292 &mut out,
293 fixture,
294 e2e_config,
295 module_path,
296 function_name,
297 result_var,
298 args,
299 field_resolver,
300 options_type,
301 options_default_fn,
302 enum_fields,
303 handle_struct_type,
304 handle_atom_list_fields,
305 );
306 }
307 if i + 1 < fixtures.len() {
308 let _ = writeln!(out);
309 }
310 }
311
312 let _ = writeln!(out, "end");
313 out
314}
315
316const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
323
324fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
326 let test_name = sanitize_ident(&fixture.id);
327 let description = fixture.description.replace('"', "\\\"");
328 let method = http.request.method.to_uppercase();
329 let path = &http.request.path;
330 let fixture_id = &fixture.id;
331
332 let _ = writeln!(out, " describe \"{test_name}\" do");
333
334 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
336 let _ = writeln!(out, " @tag :skip");
337 }
338
339 let _ = writeln!(out, " test \"{method} {path} - {description}\" do");
340
341 render_elixir_http_request(out, &http.request, fixture_id, http.expected_response.status_code);
343
344 let status = http.expected_response.status_code;
346 let _ = writeln!(out, " assert response.status == {status}");
347
348 render_elixir_body_assertions(out, &http.expected_response);
350
351 render_elixir_header_assertions(out, &http.expected_response);
353
354 let _ = writeln!(out, " end");
355 let _ = writeln!(out, " end");
356}
357
358const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
361
362fn render_elixir_http_request(out: &mut String, req: &HttpRequest, fixture_id: &str, expected_status: u16) {
364 let method = req.method.to_lowercase();
365
366 let mut opts: Vec<String> = Vec::new();
367
368 if let Some(body) = &req.body {
369 let elixir_val = json_to_elixir(body);
370 opts.push(format!("json: {elixir_val}"));
371 }
372
373 if !req.headers.is_empty() {
374 let header_pairs: Vec<String> = req
375 .headers
376 .iter()
377 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
378 .collect();
379 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
380 }
381
382 if !req.cookies.is_empty() {
383 let cookie_str = req
384 .cookies
385 .iter()
386 .map(|(k, v)| format!("{}={}", k, v))
387 .collect::<Vec<_>>()
388 .join("; ");
389 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
390 }
391
392 if !req.query_params.is_empty() {
393 let pairs: Vec<String> = req
394 .query_params
395 .iter()
396 .map(|(k, v)| {
397 let val_str = match v {
398 serde_json::Value::String(s) => s.clone(),
399 other => other.to_string(),
400 };
401 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
402 })
403 .collect();
404 opts.push(format!("params: [{}]", pairs.join(", ")));
405 }
406
407 if (300..400).contains(&expected_status) {
410 opts.push("redirect: false".to_string());
411 }
412
413 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{}\"", escape_elixir(fixture_id));
415
416 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
419 if opts.is_empty() {
420 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
421 } else {
422 let opts_str = opts.join(", ");
423 let _ = writeln!(
424 out,
425 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
426 );
427 }
428 } else {
429 opts.insert(0, format!("method: :{method}"));
431 opts.insert(1, format!("url: {url_expr}"));
432 let opts_str = opts.join(", ");
433 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
434 }
435}
436
437fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
439 if let Some(body) = &expected.body {
440 let elixir_val = json_to_elixir(body);
441 match body {
445 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
446 let _ = writeln!(
448 out,
449 " body_decoded = if is_binary(response.body), do: Jason.decode!(response.body), else: response.body"
450 );
451 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
452 }
453 _ => {
454 let _ = writeln!(out, " assert response.body == {elixir_val}");
456 }
457 }
458 }
459 if let Some(partial) = &expected.body_partial {
460 if let Some(obj) = partial.as_object() {
461 let _ = writeln!(
463 out,
464 " decoded_body = if is_binary(response.body), do: Jason.decode!(response.body), else: response.body"
465 );
466 for (key, val) in obj {
467 let key_lit = format!("\"{}\"", escape_elixir(key));
468 let elixir_val = json_to_elixir(val);
469 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
470 }
471 }
472 }
473 if let Some(errors) = &expected.validation_errors {
474 for err in errors {
475 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
476 let _ = writeln!(
477 out,
478 " assert String.contains?(Jason.encode!(response.body), {msg_lit})"
479 );
480 }
481 }
482}
483
484fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
491 for (name, value) in &expected.headers {
492 let header_key = name.to_lowercase();
493 if header_key == "content-encoding" || header_key == "connection" {
497 continue;
498 }
499 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
500 let get_header_expr = format!(
502 "Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
503 );
504 match value.as_str() {
505 "<<present>>" => {
506 let _ = writeln!(out, " assert {get_header_expr} != nil");
507 }
508 "<<absent>>" => {
509 let _ = writeln!(out, " assert {get_header_expr} == nil");
510 }
511 "<<uuid>>" => {
512 let _ = writeln!(
513 out,
514 " header_val_{} = {get_header_expr}",
515 sanitize_ident(&header_key)
516 );
517 let _ = writeln!(
518 out,
519 " 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_{}))",
520 sanitize_ident(&header_key)
521 );
522 }
523 literal => {
524 let val_lit = format!("\"{}\"", escape_elixir(literal));
525 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
526 }
527 }
528 }
529}
530
531#[allow(clippy::too_many_arguments)]
536fn render_test_case(
537 out: &mut String,
538 fixture: &Fixture,
539 e2e_config: &E2eConfig,
540 default_module_path: &str,
541 default_function_name: &str,
542 default_result_var: &str,
543 args: &[crate::config::ArgMapping],
544 field_resolver: &FieldResolver,
545 options_type: Option<&str>,
546 options_default_fn: Option<&str>,
547 enum_fields: &HashMap<String, String>,
548 handle_struct_type: Option<&str>,
549 handle_atom_list_fields: &std::collections::HashSet<String>,
550) {
551 let test_name = sanitize_ident(&fixture.id);
552 let description = fixture.description.replace('"', "\\\"");
553
554 if fixture.mock_response.is_none() {
560 let _ = writeln!(out, " describe \"{test_name}\" do");
561 let _ = writeln!(out, " @tag :skip");
562 let _ = writeln!(out, " test \"{description}\" do");
563 let _ = writeln!(
564 out,
565 " # non-HTTP fixture: Elixir binding does not expose a callable for the configured `[e2e.call]` function"
566 );
567 let _ = writeln!(out, " :ok");
568 let _ = writeln!(out, " end");
569 let _ = writeln!(out, " end");
570 return;
571 }
572
573 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
575 let lang = "elixir";
576 let call_overrides = call_config.overrides.get(lang);
577
578 let (module_path, function_name, result_var) = if fixture.call.is_some() {
581 let raw_module = call_overrides
582 .and_then(|o| o.module.as_ref())
583 .cloned()
584 .unwrap_or_else(|| call_config.module.clone());
585 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
586 {
587 raw_module.clone()
588 } else {
589 elixir_module_name(&raw_module)
590 };
591 let base_fn = call_overrides
592 .and_then(|o| o.function.as_ref())
593 .cloned()
594 .unwrap_or_else(|| call_config.function.clone());
595 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
596 format!("{base_fn}_async")
597 } else {
598 base_fn
599 };
600 (resolved_module, resolved_fn, call_config.result_var.clone())
601 } else {
602 (
603 default_module_path.to_string(),
604 default_function_name.to_string(),
605 default_result_var.to_string(),
606 )
607 };
608
609 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
610
611 let (
613 effective_args,
614 effective_options_type,
615 effective_options_default_fn,
616 effective_enum_fields,
617 effective_handle_struct_type,
618 effective_handle_atom_list_fields,
619 );
620 let empty_enum_fields_local: HashMap<String, String>;
621 let empty_atom_fields_local: std::collections::HashSet<String>;
622 let (
623 resolved_args,
624 resolved_options_type,
625 resolved_options_default_fn,
626 resolved_enum_fields_ref,
627 resolved_handle_struct_type,
628 resolved_handle_atom_list_fields_ref,
629 ) = if fixture.call.is_some() {
630 let co = call_config.overrides.get(lang);
631 effective_args = call_config.args.as_slice();
632 effective_options_type = co.and_then(|o| o.options_type.as_deref());
633 effective_options_default_fn = co.and_then(|o| o.options_via.as_deref());
634 empty_enum_fields_local = HashMap::new();
635 effective_enum_fields = co.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields_local);
636 effective_handle_struct_type = co.and_then(|o| o.handle_struct_type.as_deref());
637 empty_atom_fields_local = std::collections::HashSet::new();
638 effective_handle_atom_list_fields = co
639 .map(|o| &o.handle_atom_list_fields)
640 .unwrap_or(&empty_atom_fields_local);
641 (
642 effective_args,
643 effective_options_type,
644 effective_options_default_fn,
645 effective_enum_fields,
646 effective_handle_struct_type,
647 effective_handle_atom_list_fields,
648 )
649 } else {
650 (
651 args as &[_],
652 options_type,
653 options_default_fn,
654 enum_fields,
655 handle_struct_type,
656 handle_atom_list_fields,
657 )
658 };
659
660 let (mut setup_lines, args_str) = build_args_and_setup(
661 &fixture.input,
662 resolved_args,
663 &module_path,
664 resolved_options_type,
665 resolved_options_default_fn,
666 resolved_enum_fields_ref,
667 &fixture.id,
668 resolved_handle_struct_type,
669 resolved_handle_atom_list_fields_ref,
670 );
671
672 let final_args = if let Some(visitor_spec) = &fixture.visitor {
675 let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
676 format!("{args_str}, {visitor_var}")
677 } else {
678 args_str
679 };
680
681 let _ = writeln!(out, " describe \"{test_name}\" do");
682 let _ = writeln!(out, " test \"{description}\" do");
683
684 for line in &setup_lines {
685 let _ = writeln!(out, " {line}");
686 }
687
688 let returns_result = call_config.returns_result;
689
690 if expects_error {
691 if returns_result {
692 let _ = writeln!(
693 out,
694 " assert {{:error, _}} = {module_path}.{function_name}({final_args})"
695 );
696 } else {
697 let _ = writeln!(out, " _result = {module_path}.{function_name}({final_args})");
699 }
700 let _ = writeln!(out, " end");
701 let _ = writeln!(out, " end");
702 return;
703 }
704
705 if returns_result {
706 let _ = writeln!(
707 out,
708 " {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
709 );
710 } else {
711 let _ = writeln!(out, " {result_var} = {module_path}.{function_name}({final_args})");
713 }
714
715 for assertion in &fixture.assertions {
716 render_assertion(out, assertion, &result_var, field_resolver, &module_path);
717 }
718
719 let _ = writeln!(out, " end");
720 let _ = writeln!(out, " end");
721}
722
723#[allow(clippy::too_many_arguments)]
727fn build_args_and_setup(
728 input: &serde_json::Value,
729 args: &[crate::config::ArgMapping],
730 module_path: &str,
731 options_type: Option<&str>,
732 options_default_fn: Option<&str>,
733 enum_fields: &HashMap<String, String>,
734 fixture_id: &str,
735 _handle_struct_type: Option<&str>,
736 _handle_atom_list_fields: &std::collections::HashSet<String>,
737) -> (Vec<String>, String) {
738 if args.is_empty() {
739 return (Vec::new(), json_to_elixir(input));
740 }
741
742 let mut setup_lines: Vec<String> = Vec::new();
743 let mut parts: Vec<String> = Vec::new();
744
745 for arg in args {
746 if arg.arg_type == "mock_url" {
747 setup_lines.push(format!(
748 "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
749 arg.name,
750 ));
751 parts.push(arg.name.clone());
752 continue;
753 }
754
755 if arg.arg_type == "handle" {
756 let constructor_name = format!("create_{}", arg.name.to_snake_case());
760 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
761 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
762 let name = &arg.name;
763 if config_value.is_null()
764 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
765 {
766 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
767 } else {
768 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
771 let escaped = escape_elixir(&json_str);
772 setup_lines.push(format!("{name}_config = \"{escaped}\""));
773 setup_lines.push(format!(
774 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
775 ));
776 }
777 parts.push(arg.name.clone());
778 continue;
779 }
780
781 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
782 let val = input.get(field);
783 match val {
784 None | Some(serde_json::Value::Null) if arg.optional => {
785 parts.push("nil".to_string());
788 continue;
789 }
790 None | Some(serde_json::Value::Null) => {
791 let default_val = match arg.arg_type.as_str() {
793 "string" => "\"\"".to_string(),
794 "int" | "integer" => "0".to_string(),
795 "float" | "number" => "0.0".to_string(),
796 "bool" | "boolean" => "false".to_string(),
797 _ => "nil".to_string(),
798 };
799 parts.push(default_val);
800 }
801 Some(v) => {
802 if arg.arg_type == "file_path" {
805 if let Some(path_str) = v.as_str() {
806 let full_path = format!("../../test_documents/{path_str}");
807 parts.push(format!("\"{}\"", escape_elixir(&full_path)));
808 continue;
809 }
810 }
811 if arg.arg_type == "bytes" {
814 if let Some(raw) = v.as_str() {
815 let var_name = &arg.name;
816 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
817 parts.push(format!("\"{}\"", escape_elixir(raw)));
819 } else {
820 let first = raw.chars().next().unwrap_or('\0');
821 let is_file_path = (first.is_ascii_alphanumeric() || first == '_')
822 && raw
823 .find('/')
824 .is_some_and(|slash_pos| slash_pos > 0 && raw[slash_pos + 1..].contains('.'));
825 if is_file_path {
826 let full_path = format!("../../test_documents/{raw}");
828 let escaped = escape_elixir(&full_path);
829 setup_lines.push(format!("{var_name} = File.read!(\"{escaped}\")"));
830 parts.push(var_name.to_string());
831 } else {
832 setup_lines.push(format!(
834 "{var_name} = Base.decode64!(\"{}\", padding: false)",
835 escape_elixir(raw)
836 ));
837 parts.push(var_name.to_string());
838 }
839 }
840 continue;
841 }
842 }
843 if arg.arg_type == "json_object" && !v.is_null() {
845 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
846 (options_type, options_default_fn, v.as_object())
847 {
848 let options_var = "options";
850 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
851
852 for (k, vv) in obj.iter() {
854 let snake_key = k.to_snake_case();
855 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
856 if let Some(s) = vv.as_str() {
857 let snake_val = s.to_snake_case();
858 format!(":{snake_val}")
860 } else {
861 json_to_elixir(vv)
862 }
863 } else {
864 json_to_elixir(vv)
865 };
866 setup_lines.push(format!(
867 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
868 ));
869 }
870
871 parts.push(options_var.to_string());
873 continue;
874 }
875 if !v.is_null() {
879 let json_str = serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string());
880 let escaped = escape_elixir(&json_str);
881 parts.push(format!("\"{escaped}\""));
882 continue;
883 }
884 }
885 parts.push(json_to_elixir(v));
886 }
887 }
888 }
889
890 (setup_lines, parts.join(", "))
891}
892
893fn is_numeric_expr(field_expr: &str) -> bool {
896 field_expr.starts_with("length(")
897}
898
899fn render_assertion(
900 out: &mut String,
901 assertion: &Assertion,
902 result_var: &str,
903 field_resolver: &FieldResolver,
904 module_path: &str,
905) {
906 if let Some(f) = &assertion.field {
909 match f.as_str() {
910 "chunks_have_content" => {
911 let pred =
912 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
913 match assertion.assertion_type.as_str() {
914 "is_true" => {
915 let _ = writeln!(out, " assert {pred}");
916 }
917 "is_false" => {
918 let _ = writeln!(out, " refute {pred}");
919 }
920 _ => {
921 let _ = writeln!(
922 out,
923 " # skipped: unsupported assertion type on synthetic field '{f}'"
924 );
925 }
926 }
927 return;
928 }
929 "chunks_have_embeddings" => {
930 let pred = format!(
931 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
932 );
933 match assertion.assertion_type.as_str() {
934 "is_true" => {
935 let _ = writeln!(out, " assert {pred}");
936 }
937 "is_false" => {
938 let _ = writeln!(out, " refute {pred}");
939 }
940 _ => {
941 let _ = writeln!(
942 out,
943 " # skipped: unsupported assertion type on synthetic field '{f}'"
944 );
945 }
946 }
947 return;
948 }
949 "embeddings" => {
953 match assertion.assertion_type.as_str() {
954 "count_equals" => {
955 if let Some(val) = &assertion.value {
956 let ex_val = json_to_elixir(val);
957 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
958 }
959 }
960 "count_min" => {
961 if let Some(val) = &assertion.value {
962 let ex_val = json_to_elixir(val);
963 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
964 }
965 }
966 "not_empty" => {
967 let _ = writeln!(out, " assert {result_var} != []");
968 }
969 "is_empty" => {
970 let _ = writeln!(out, " assert {result_var} == []");
971 }
972 _ => {
973 let _ = writeln!(
974 out,
975 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
976 );
977 }
978 }
979 return;
980 }
981 "embedding_dimensions" => {
982 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
983 match assertion.assertion_type.as_str() {
984 "equals" => {
985 if let Some(val) = &assertion.value {
986 let ex_val = json_to_elixir(val);
987 let _ = writeln!(out, " assert {expr} == {ex_val}");
988 }
989 }
990 "greater_than" => {
991 if let Some(val) = &assertion.value {
992 let ex_val = json_to_elixir(val);
993 let _ = writeln!(out, " assert {expr} > {ex_val}");
994 }
995 }
996 _ => {
997 let _ = writeln!(
998 out,
999 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1000 );
1001 }
1002 }
1003 return;
1004 }
1005 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1006 let pred = match f.as_str() {
1007 "embeddings_valid" => {
1008 format!("Enum.all?({result_var}, fn e -> e != [] end)")
1009 }
1010 "embeddings_finite" => {
1011 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
1012 }
1013 "embeddings_non_zero" => {
1014 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
1015 }
1016 "embeddings_normalized" => {
1017 format!(
1018 "Enum.all?({result_var}, fn e -> n = Enum.reduce(e, 0.0, fn v, acc -> acc + v * v end); abs(n - 1.0) < 1.0e-3 end)"
1019 )
1020 }
1021 _ => unreachable!(),
1022 };
1023 match assertion.assertion_type.as_str() {
1024 "is_true" => {
1025 let _ = writeln!(out, " assert {pred}");
1026 }
1027 "is_false" => {
1028 let _ = writeln!(out, " refute {pred}");
1029 }
1030 _ => {
1031 let _ = writeln!(
1032 out,
1033 " # skipped: unsupported assertion type on synthetic field '{f}'"
1034 );
1035 }
1036 }
1037 return;
1038 }
1039 "keywords" | "keywords_count" => {
1042 let _ = writeln!(
1043 out,
1044 " # skipped: field '{f}' not available on Elixir ExtractionResult"
1045 );
1046 return;
1047 }
1048 _ => {}
1049 }
1050 }
1051
1052 if let Some(f) = &assertion.field {
1054 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1055 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
1056 return;
1057 }
1058 }
1059
1060 let field_expr = match &assertion.field {
1061 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
1062 _ => result_var.to_string(),
1063 };
1064
1065 let is_numeric = is_numeric_expr(&field_expr);
1068 let trimmed_field_expr = if is_numeric {
1069 field_expr.clone()
1070 } else {
1071 format!("String.trim({field_expr})")
1072 };
1073
1074 match assertion.assertion_type.as_str() {
1075 "equals" => {
1076 if let Some(expected) = &assertion.value {
1077 let elixir_val = json_to_elixir(expected);
1078 let is_string_expected = expected.is_string();
1080 if is_string_expected && !is_numeric {
1081 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
1082 } else {
1083 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
1084 }
1085 }
1086 }
1087 "contains" => {
1088 if let Some(expected) = &assertion.value {
1089 let elixir_val = json_to_elixir(expected);
1090 let _ = writeln!(
1092 out,
1093 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1094 );
1095 }
1096 }
1097 "contains_all" => {
1098 if let Some(values) = &assertion.values {
1099 for val in values {
1100 let elixir_val = json_to_elixir(val);
1101 let _ = writeln!(
1102 out,
1103 " assert String.contains?(to_string({field_expr}), {elixir_val})"
1104 );
1105 }
1106 }
1107 }
1108 "not_contains" => {
1109 if let Some(expected) = &assertion.value {
1110 let elixir_val = json_to_elixir(expected);
1111 let _ = writeln!(
1112 out,
1113 " refute String.contains?(to_string({field_expr}), {elixir_val})"
1114 );
1115 }
1116 }
1117 "not_empty" => {
1118 let _ = writeln!(out, " assert {field_expr} != \"\"");
1119 }
1120 "is_empty" => {
1121 if is_numeric {
1122 let _ = writeln!(out, " assert {field_expr} == 0");
1124 } else {
1125 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
1127 }
1128 }
1129 "contains_any" => {
1130 if let Some(values) = &assertion.values {
1131 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
1132 let list_str = items.join(", ");
1133 let _ = writeln!(
1134 out,
1135 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
1136 );
1137 }
1138 }
1139 "greater_than" => {
1140 if let Some(val) = &assertion.value {
1141 let elixir_val = json_to_elixir(val);
1142 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
1143 }
1144 }
1145 "less_than" => {
1146 if let Some(val) = &assertion.value {
1147 let elixir_val = json_to_elixir(val);
1148 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
1149 }
1150 }
1151 "greater_than_or_equal" => {
1152 if let Some(val) = &assertion.value {
1153 let elixir_val = json_to_elixir(val);
1154 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
1155 }
1156 }
1157 "less_than_or_equal" => {
1158 if let Some(val) = &assertion.value {
1159 let elixir_val = json_to_elixir(val);
1160 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
1161 }
1162 }
1163 "starts_with" => {
1164 if let Some(expected) = &assertion.value {
1165 let elixir_val = json_to_elixir(expected);
1166 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
1167 }
1168 }
1169 "ends_with" => {
1170 if let Some(expected) = &assertion.value {
1171 let elixir_val = json_to_elixir(expected);
1172 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
1173 }
1174 }
1175 "min_length" => {
1176 if let Some(val) = &assertion.value {
1177 if let Some(n) = val.as_u64() {
1178 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1179 }
1180 }
1181 }
1182 "max_length" => {
1183 if let Some(val) = &assertion.value {
1184 if let Some(n) = val.as_u64() {
1185 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1186 }
1187 }
1188 }
1189 "count_min" => {
1190 if let Some(val) = &assertion.value {
1191 if let Some(n) = val.as_u64() {
1192 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1193 }
1194 }
1195 }
1196 "count_equals" => {
1197 if let Some(val) = &assertion.value {
1198 if let Some(n) = val.as_u64() {
1199 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1200 }
1201 }
1202 }
1203 "is_true" => {
1204 let _ = writeln!(out, " assert {field_expr} == true");
1205 }
1206 "is_false" => {
1207 let _ = writeln!(out, " assert {field_expr} == false");
1208 }
1209 "method_result" => {
1210 if let Some(method_name) = &assertion.method {
1211 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1212 let check = assertion.check.as_deref().unwrap_or("is_true");
1213 match check {
1214 "equals" => {
1215 if let Some(val) = &assertion.value {
1216 let elixir_val = json_to_elixir(val);
1217 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1218 }
1219 }
1220 "is_true" => {
1221 let _ = writeln!(out, " assert {call_expr} == true");
1222 }
1223 "is_false" => {
1224 let _ = writeln!(out, " assert {call_expr} == false");
1225 }
1226 "greater_than_or_equal" => {
1227 if let Some(val) = &assertion.value {
1228 let n = val.as_u64().unwrap_or(0);
1229 let _ = writeln!(out, " assert {call_expr} >= {n}");
1230 }
1231 }
1232 "count_min" => {
1233 if let Some(val) = &assertion.value {
1234 let n = val.as_u64().unwrap_or(0);
1235 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1236 }
1237 }
1238 "contains" => {
1239 if let Some(val) = &assertion.value {
1240 let elixir_val = json_to_elixir(val);
1241 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1242 }
1243 }
1244 "is_error" => {
1245 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1246 }
1247 other_check => {
1248 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1249 }
1250 }
1251 } else {
1252 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1253 }
1254 }
1255 "matches_regex" => {
1256 if let Some(expected) = &assertion.value {
1257 let elixir_val = json_to_elixir(expected);
1258 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1259 }
1260 }
1261 "not_error" => {
1262 }
1264 "error" => {
1265 }
1267 other => {
1268 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1269 }
1270 }
1271}
1272
1273fn build_elixir_method_call(
1276 result_var: &str,
1277 method_name: &str,
1278 args: Option<&serde_json::Value>,
1279 module_path: &str,
1280) -> String {
1281 match method_name {
1282 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1283 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1284 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1285 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1286 "contains_node_type" => {
1287 let node_type = args
1288 .and_then(|a| a.get("node_type"))
1289 .and_then(|v| v.as_str())
1290 .unwrap_or("");
1291 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1292 }
1293 "find_nodes_by_type" => {
1294 let node_type = args
1295 .and_then(|a| a.get("node_type"))
1296 .and_then(|v| v.as_str())
1297 .unwrap_or("");
1298 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1299 }
1300 "run_query" => {
1301 let query_source = args
1302 .and_then(|a| a.get("query_source"))
1303 .and_then(|v| v.as_str())
1304 .unwrap_or("");
1305 let language = args
1306 .and_then(|a| a.get("language"))
1307 .and_then(|v| v.as_str())
1308 .unwrap_or("");
1309 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1310 }
1311 _ => format!("{module_path}.{method_name}({result_var})"),
1312 }
1313}
1314
1315fn elixir_module_name(category: &str) -> String {
1317 use heck::ToUpperCamelCase;
1318 category.to_upper_camel_case()
1319}
1320
1321fn json_to_elixir(value: &serde_json::Value) -> String {
1323 match value {
1324 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1325 serde_json::Value::Bool(true) => "true".to_string(),
1326 serde_json::Value::Bool(false) => "false".to_string(),
1327 serde_json::Value::Number(n) => {
1328 let s = n.to_string().replace("e+", "e");
1332 if s.contains('e') && !s.contains('.') {
1333 s.replacen('e', ".0e", 1)
1335 } else {
1336 s
1337 }
1338 }
1339 serde_json::Value::Null => "nil".to_string(),
1340 serde_json::Value::Array(arr) => {
1341 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1342 format!("[{}]", items.join(", "))
1343 }
1344 serde_json::Value::Object(map) => {
1345 let entries: Vec<String> = map
1346 .iter()
1347 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1348 .collect();
1349 format!("%{{{}}}", entries.join(", "))
1350 }
1351 }
1352}
1353
1354fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1356 use std::fmt::Write as FmtWrite;
1357 let mut visitor_obj = String::new();
1358 let _ = writeln!(visitor_obj, "%{{");
1359 for (method_name, action) in &visitor_spec.callbacks {
1360 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1361 }
1362 let _ = writeln!(visitor_obj, " }}");
1363
1364 setup_lines.push(format!("visitor = {visitor_obj}"));
1365 "visitor".to_string()
1366}
1367
1368fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1370 use std::fmt::Write as FmtWrite;
1371
1372 let handle_method = format!("handle_{}", &method_name[6..]); let params = match method_name {
1375 "visit_link" => "_ctx, _href, _text, _title",
1376 "visit_image" => "_ctx, _src, _alt, _title",
1377 "visit_heading" => "_ctx, _level, text, _id",
1378 "visit_code_block" => "_ctx, _lang, _code",
1379 "visit_code_inline"
1380 | "visit_strong"
1381 | "visit_emphasis"
1382 | "visit_strikethrough"
1383 | "visit_underline"
1384 | "visit_subscript"
1385 | "visit_superscript"
1386 | "visit_mark"
1387 | "visit_button"
1388 | "visit_summary"
1389 | "visit_figcaption"
1390 | "visit_definition_term"
1391 | "visit_definition_description" => "_ctx, _text",
1392 "visit_text" => "_ctx, _text",
1393 "visit_list_item" => "_ctx, _ordered, _marker, _text",
1394 "visit_blockquote" => "_ctx, _content, _depth",
1395 "visit_table_row" => "_ctx, _cells, _is_header",
1396 "visit_custom_element" => "_ctx, _tag_name, _html",
1397 "visit_form" => "_ctx, _action_url, _method",
1398 "visit_input" => "_ctx, _input_type, _name, _value",
1399 "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1400 "visit_details" => "_ctx, _is_open",
1401 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "_ctx, _output",
1402 "visit_list_start" => "_ctx, _ordered",
1403 "visit_list_end" => "_ctx, _ordered, _output",
1404 _ => "_ctx",
1405 };
1406
1407 let _ = writeln!(out, " :{handle_method} => fn({params}) ->");
1408 match action {
1409 CallbackAction::Skip => {
1410 let _ = writeln!(out, " :skip");
1411 }
1412 CallbackAction::Continue => {
1413 let _ = writeln!(out, " :continue");
1414 }
1415 CallbackAction::PreserveHtml => {
1416 let _ = writeln!(out, " :preserve_html");
1417 }
1418 CallbackAction::Custom { output } => {
1419 let escaped = escape_elixir(output);
1420 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1421 }
1422 CallbackAction::CustomTemplate { template } => {
1423 let escaped = escape_elixir(template);
1425 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1426 }
1427 }
1428 let _ = writeln!(out, " end,");
1429}