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()));
78
79 files.push(GeneratedFile {
81 path: output_base.join("mix.exs"),
82 content: render_mix_exs("", "", "", e2e_config.dep_mode, has_http_tests),
83 generated_header: false,
84 });
85
86 files.push(GeneratedFile {
88 path: output_base.join("lib").join("e2e_elixir.ex"),
89 content: "defmodule E2eElixir do\n @moduledoc false\nend\n".to_string(),
90 generated_header: false,
91 });
92
93 files.push(GeneratedFile {
95 path: output_base.join("test").join("test_helper.exs"),
96 content: render_test_helper(has_http_tests),
97 generated_header: false,
98 });
99
100 for group in groups {
102 let active: Vec<&Fixture> = group
103 .fixtures
104 .iter()
105 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
106 .collect();
107
108 if active.is_empty() {
109 continue;
110 }
111
112 let filename = format!("{}_test.exs", sanitize_filename(&group.category));
113 let field_resolver = FieldResolver::new(
114 &e2e_config.fields,
115 &e2e_config.fields_optional,
116 &e2e_config.result_fields,
117 &e2e_config.fields_array,
118 );
119 let content = render_test_file(
120 &group.category,
121 &active,
122 e2e_config,
123 &module_path,
124 &function_name,
125 result_var,
126 &e2e_config.call.args,
127 &field_resolver,
128 options_type.as_deref(),
129 options_default_fn.as_deref(),
130 enum_fields,
131 handle_struct_type.as_deref(),
132 handle_atom_list_fields,
133 );
134 files.push(GeneratedFile {
135 path: output_base.join("test").join(filename),
136 content,
137 generated_header: true,
138 });
139 }
140
141 Ok(files)
142 }
143
144 fn language_name(&self) -> &'static str {
145 "elixir"
146 }
147}
148
149fn render_test_helper(has_http_tests: bool) -> String {
150 if has_http_tests {
151 r#"ExUnit.start()
152
153# Spawn mock-server binary and set MOCK_SERVER_URL for all tests.
154mock_server_bin = Path.expand("../../rust/target/release/mock-server", __DIR__)
155fixtures_dir = Path.expand("../../../fixtures", __DIR__)
156
157if File.exists?(mock_server_bin) do
158 port = Port.open({:spawn_executable, mock_server_bin}, [
159 :binary,
160 :line,
161 args: [fixtures_dir]
162 ])
163 receive do
164 {^port, {:data, {:eol, "MOCK_SERVER_URL=" <> url}}} ->
165 System.put_env("MOCK_SERVER_URL", url)
166 after
167 30_000 ->
168 raise "mock-server startup timeout"
169 end
170end
171"#
172 .to_string()
173 } else {
174 "ExUnit.start()\n".to_string()
175 }
176}
177
178fn render_mix_exs(
179 _dep_atom: &str,
180 _pkg_path: &str,
181 _dep_version: &str,
182 _dep_mode: crate::config::DependencyMode,
183 has_http_tests: bool,
184) -> String {
185 let mut out = String::new();
186 let _ = writeln!(out, "defmodule E2eElixir.MixProject do");
187 let _ = writeln!(out, " use Mix.Project");
188 let _ = writeln!(out);
189 let _ = writeln!(out, " def project do");
190 let _ = writeln!(out, " [");
191 let _ = writeln!(out, " app: :e2e_elixir,");
192 let _ = writeln!(out, " version: \"0.1.0\",");
193 let _ = writeln!(out, " elixir: \"~> 1.14\",");
194 let _ = writeln!(out, " deps: deps()");
195 let _ = writeln!(out, " ]");
196 let _ = writeln!(out, " end");
197 let _ = writeln!(out);
198 let _ = writeln!(out, " defp deps do");
199 let _ = writeln!(out, " [");
200 if has_http_tests {
203 let _ = writeln!(out, " {{:req, \"{req}\"}},", req = tv::hex::REQ);
204 let _ = writeln!(out, " {{:jason, \"{jason}\"}}", jason = tv::hex::JASON);
205 }
206 let _ = writeln!(out, " ]");
207 let _ = writeln!(out, " end");
208 let _ = writeln!(out, "end");
209 out
210}
211
212#[allow(clippy::too_many_arguments)]
213fn render_test_file(
214 category: &str,
215 fixtures: &[&Fixture],
216 e2e_config: &E2eConfig,
217 module_path: &str,
218 function_name: &str,
219 result_var: &str,
220 args: &[crate::config::ArgMapping],
221 field_resolver: &FieldResolver,
222 options_type: Option<&str>,
223 options_default_fn: Option<&str>,
224 enum_fields: &HashMap<String, String>,
225 handle_struct_type: Option<&str>,
226 handle_atom_list_fields: &std::collections::HashSet<String>,
227) -> String {
228 let mut out = String::new();
229 out.push_str(&hash::header(CommentStyle::Hash));
230 let _ = writeln!(out, "# E2e tests for category: {category}");
231 let _ = writeln!(out, "defmodule E2e.{}Test do", elixir_module_name(category));
232 let _ = writeln!(out, " use ExUnit.Case, async: true");
233
234 let has_http = fixtures.iter().any(|f| f.is_http_test());
236 if has_http {
237 let _ = writeln!(out);
238 let _ = writeln!(out, " defp mock_server_url do");
239 let _ = writeln!(
240 out,
241 " System.get_env(\"MOCK_SERVER_URL\") || \"http://localhost:8080\""
242 );
243 let _ = writeln!(out, " end");
244 }
245
246 let _ = writeln!(out);
247
248 for (i, fixture) in fixtures.iter().enumerate() {
249 if let Some(http) = &fixture.http {
250 render_http_test_case(&mut out, fixture, http);
251 } else {
252 render_test_case(
253 &mut out,
254 fixture,
255 e2e_config,
256 module_path,
257 function_name,
258 result_var,
259 args,
260 field_resolver,
261 options_type,
262 options_default_fn,
263 enum_fields,
264 handle_struct_type,
265 handle_atom_list_fields,
266 );
267 }
268 if i + 1 < fixtures.len() {
269 let _ = writeln!(out);
270 }
271 }
272
273 let _ = writeln!(out, "end");
274 out
275}
276
277const FINCH_UNSUPPORTED_METHODS: &[&str] = &["TRACE", "CONNECT"];
284
285fn render_http_test_case(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
287 let test_name = sanitize_ident(&fixture.id);
288 let description = fixture.description.replace('"', "\\\"");
289 let method = http.request.method.to_uppercase();
290 let path = &http.request.path;
291 let fixture_id = &fixture.id;
292
293 let _ = writeln!(out, " describe \"{test_name}\" do");
294
295 if FINCH_UNSUPPORTED_METHODS.contains(&method.as_str()) {
297 let _ = writeln!(out, " @tag :skip");
298 }
299
300 let _ = writeln!(out, " test \"{method} {path} - {description}\" do");
301
302 render_elixir_http_request(out, &http.request, fixture_id, http.expected_response.status_code);
304
305 let status = http.expected_response.status_code;
307 let _ = writeln!(out, " assert response.status == {status}");
308
309 render_elixir_body_assertions(out, &http.expected_response);
311
312 render_elixir_header_assertions(out, &http.expected_response);
314
315 let _ = writeln!(out, " end");
316 let _ = writeln!(out, " end");
317}
318
319const REQ_CONVENIENCE_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
322
323fn render_elixir_http_request(out: &mut String, req: &HttpRequest, fixture_id: &str, expected_status: u16) {
325 let method = req.method.to_lowercase();
326
327 let mut opts: Vec<String> = Vec::new();
328
329 if let Some(body) = &req.body {
330 let elixir_val = json_to_elixir(body);
331 opts.push(format!("json: {elixir_val}"));
332 }
333
334 if !req.headers.is_empty() {
335 let header_pairs: Vec<String> = req
336 .headers
337 .iter()
338 .map(|(k, v)| format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(v)))
339 .collect();
340 opts.push(format!("headers: [{}]", header_pairs.join(", ")));
341 }
342
343 if !req.cookies.is_empty() {
344 let cookie_str = req
345 .cookies
346 .iter()
347 .map(|(k, v)| format!("{}={}", k, v))
348 .collect::<Vec<_>>()
349 .join("; ");
350 opts.push(format!("headers: [{{\"cookie\", \"{}\"}}]", escape_elixir(&cookie_str)));
351 }
352
353 if !req.query_params.is_empty() {
354 let pairs: Vec<String> = req
355 .query_params
356 .iter()
357 .map(|(k, v)| {
358 let val_str = match v {
359 serde_json::Value::String(s) => s.clone(),
360 other => other.to_string(),
361 };
362 format!("{{\"{}\", \"{}\"}}", escape_elixir(k), escape_elixir(&val_str))
363 })
364 .collect();
365 opts.push(format!("params: [{}]", pairs.join(", ")));
366 }
367
368 if (300..400).contains(&expected_status) {
371 opts.push("redirect: false".to_string());
372 }
373
374 let url_expr = format!("\"#{{mock_server_url()}}/fixtures/{}\"", escape_elixir(fixture_id));
376
377 if REQ_CONVENIENCE_METHODS.contains(&method.as_str()) {
380 if opts.is_empty() {
381 let _ = writeln!(out, " {{:ok, response}} = Req.{method}(url: {url_expr})");
382 } else {
383 let opts_str = opts.join(", ");
384 let _ = writeln!(
385 out,
386 " {{:ok, response}} = Req.{method}(url: {url_expr}, {opts_str})"
387 );
388 }
389 } else {
390 opts.insert(0, format!("method: :{method}"));
392 opts.insert(1, format!("url: {url_expr}"));
393 let opts_str = opts.join(", ");
394 let _ = writeln!(out, " {{:ok, response}} = Req.request({opts_str})");
395 }
396}
397
398fn render_elixir_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
400 if let Some(body) = &expected.body {
401 let elixir_val = json_to_elixir(body);
402 match body {
406 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
407 let _ = writeln!(
409 out,
410 " body_decoded = if is_binary(response.body), do: Jason.decode!(response.body), else: response.body"
411 );
412 let _ = writeln!(out, " assert body_decoded == {elixir_val}");
413 }
414 _ => {
415 let _ = writeln!(out, " assert response.body == {elixir_val}");
417 }
418 }
419 }
420 if let Some(partial) = &expected.body_partial {
421 if let Some(obj) = partial.as_object() {
422 let _ = writeln!(
424 out,
425 " decoded_body = if is_binary(response.body), do: Jason.decode!(response.body), else: response.body"
426 );
427 for (key, val) in obj {
428 let key_lit = format!("\"{}\"", escape_elixir(key));
429 let elixir_val = json_to_elixir(val);
430 let _ = writeln!(out, " assert decoded_body[{key_lit}] == {elixir_val}");
431 }
432 }
433 }
434 if let Some(errors) = &expected.validation_errors {
435 for err in errors {
436 let msg_lit = format!("\"{}\"", escape_elixir(&err.msg));
437 let _ = writeln!(
438 out,
439 " assert String.contains?(Jason.encode!(response.body), {msg_lit})"
440 );
441 }
442 }
443}
444
445fn render_elixir_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
452 for (name, value) in &expected.headers {
453 let header_key = name.to_lowercase();
454 let key_lit = format!("\"{}\"", escape_elixir(&header_key));
455 let get_header_expr = format!(
457 "Enum.find_value(response.headers, fn {{k, v}} -> if String.downcase(k) == {key_lit}, do: List.first(List.wrap(v)) end)"
458 );
459 match value.as_str() {
460 "<<present>>" => {
461 let _ = writeln!(out, " assert {get_header_expr} != nil");
462 }
463 "<<absent>>" => {
464 let _ = writeln!(out, " assert {get_header_expr} == nil");
465 }
466 "<<uuid>>" => {
467 let _ = writeln!(
468 out,
469 " header_val_{} = {get_header_expr}",
470 sanitize_ident(&header_key)
471 );
472 let _ = writeln!(
473 out,
474 " 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_{}))",
475 sanitize_ident(&header_key)
476 );
477 }
478 literal => {
479 let val_lit = format!("\"{}\"", escape_elixir(literal));
480 let _ = writeln!(out, " assert {get_header_expr} == {val_lit}");
481 }
482 }
483 }
484}
485
486#[allow(clippy::too_many_arguments)]
491fn render_test_case(
492 out: &mut String,
493 fixture: &Fixture,
494 e2e_config: &E2eConfig,
495 default_module_path: &str,
496 default_function_name: &str,
497 default_result_var: &str,
498 args: &[crate::config::ArgMapping],
499 field_resolver: &FieldResolver,
500 options_type: Option<&str>,
501 options_default_fn: Option<&str>,
502 enum_fields: &HashMap<String, String>,
503 handle_struct_type: Option<&str>,
504 handle_atom_list_fields: &std::collections::HashSet<String>,
505) {
506 let test_name = sanitize_ident(&fixture.id);
507 let description = fixture.description.replace('"', "\\\"");
508
509 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
511 let lang = "elixir";
512 let call_overrides = call_config.overrides.get(lang);
513
514 let (module_path, function_name, result_var) = if fixture.call.is_some() {
517 let raw_module = call_overrides
518 .and_then(|o| o.module.as_ref())
519 .cloned()
520 .unwrap_or_else(|| call_config.module.clone());
521 let resolved_module = if raw_module.contains('.') || raw_module.chars().next().is_some_and(|c| c.is_uppercase())
522 {
523 raw_module.clone()
524 } else {
525 elixir_module_name(&raw_module)
526 };
527 let base_fn = call_overrides
528 .and_then(|o| o.function.as_ref())
529 .cloned()
530 .unwrap_or_else(|| call_config.function.clone());
531 let resolved_fn = if call_config.r#async && !base_fn.ends_with("_async") {
532 format!("{base_fn}_async")
533 } else {
534 base_fn
535 };
536 (resolved_module, resolved_fn, call_config.result_var.clone())
537 } else {
538 (
539 default_module_path.to_string(),
540 default_function_name.to_string(),
541 default_result_var.to_string(),
542 )
543 };
544
545 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
546
547 let (mut setup_lines, args_str) = build_args_and_setup(
548 &fixture.input,
549 args,
550 &module_path,
551 options_type,
552 options_default_fn,
553 enum_fields,
554 &fixture.id,
555 handle_struct_type,
556 handle_atom_list_fields,
557 );
558
559 let final_args = if let Some(visitor_spec) = &fixture.visitor {
562 let visitor_var = build_elixir_visitor(&mut setup_lines, visitor_spec);
563 format!("{args_str}, {visitor_var}")
564 } else {
565 args_str
566 };
567
568 let _ = writeln!(out, " describe \"{test_name}\" do");
569 let _ = writeln!(out, " test \"{description}\" do");
570
571 for line in &setup_lines {
572 let _ = writeln!(out, " {line}");
573 }
574
575 if expects_error {
576 let _ = writeln!(
577 out,
578 " assert {{:error, _}} = {module_path}.{function_name}({final_args})"
579 );
580 let _ = writeln!(out, " end");
581 let _ = writeln!(out, " end");
582 return;
583 }
584
585 let _ = writeln!(
586 out,
587 " {{:ok, {result_var}}} = {module_path}.{function_name}({final_args})"
588 );
589
590 for assertion in &fixture.assertions {
591 render_assertion(out, assertion, &result_var, field_resolver, &module_path);
592 }
593
594 let _ = writeln!(out, " end");
595 let _ = writeln!(out, " end");
596}
597
598#[allow(clippy::too_many_arguments)]
602fn build_args_and_setup(
603 input: &serde_json::Value,
604 args: &[crate::config::ArgMapping],
605 module_path: &str,
606 options_type: Option<&str>,
607 options_default_fn: Option<&str>,
608 enum_fields: &HashMap<String, String>,
609 fixture_id: &str,
610 _handle_struct_type: Option<&str>,
611 _handle_atom_list_fields: &std::collections::HashSet<String>,
612) -> (Vec<String>, String) {
613 if args.is_empty() {
614 return (Vec::new(), json_to_elixir(input));
615 }
616
617 let mut setup_lines: Vec<String> = Vec::new();
618 let mut parts: Vec<String> = Vec::new();
619
620 for arg in args {
621 if arg.arg_type == "mock_url" {
622 setup_lines.push(format!(
623 "{} = System.get_env(\"MOCK_SERVER_URL\") <> \"/fixtures/{fixture_id}\"",
624 arg.name,
625 ));
626 parts.push(arg.name.clone());
627 continue;
628 }
629
630 if arg.arg_type == "handle" {
631 let constructor_name = format!("create_{}", arg.name.to_snake_case());
635 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
636 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
637 let name = &arg.name;
638 if config_value.is_null()
639 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
640 {
641 setup_lines.push(format!("{{:ok, {name}}} = {module_path}.{constructor_name}(nil)"));
642 } else {
643 let json_str = serde_json::to_string(config_value).unwrap_or_else(|_| "{}".to_string());
646 let escaped = escape_elixir(&json_str);
647 setup_lines.push(format!("{name}_config = \"{escaped}\""));
648 setup_lines.push(format!(
649 "{{:ok, {name}}} = {module_path}.{constructor_name}({name}_config)",
650 ));
651 }
652 parts.push(arg.name.clone());
653 continue;
654 }
655
656 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
657 let val = input.get(field);
658 match val {
659 None | Some(serde_json::Value::Null) if arg.optional => {
660 continue;
662 }
663 None | Some(serde_json::Value::Null) => {
664 let default_val = match arg.arg_type.as_str() {
666 "string" => "\"\"".to_string(),
667 "int" | "integer" => "0".to_string(),
668 "float" | "number" => "0.0".to_string(),
669 "bool" | "boolean" => "false".to_string(),
670 _ => "nil".to_string(),
671 };
672 parts.push(default_val);
673 }
674 Some(v) => {
675 if arg.arg_type == "json_object" && !v.is_null() {
677 if let (Some(_opts_type), Some(options_fn), Some(obj)) =
678 (options_type, options_default_fn, v.as_object())
679 {
680 let options_var = "options";
682 setup_lines.push(format!("{options_var} = {module_path}.{options_fn}()"));
683
684 for (k, vv) in obj.iter() {
686 let snake_key = k.to_snake_case();
687 let elixir_val = if let Some(_enum_type) = enum_fields.get(k) {
688 if let Some(s) = vv.as_str() {
689 let snake_val = s.to_snake_case();
690 format!(":{snake_val}")
692 } else {
693 json_to_elixir(vv)
694 }
695 } else {
696 json_to_elixir(vv)
697 };
698 setup_lines.push(format!(
699 "{options_var} = %{{{options_var} | {snake_key}: {elixir_val}}}"
700 ));
701 }
702
703 parts.push(options_var.to_string());
705 continue;
706 }
707 }
708 parts.push(json_to_elixir(v));
709 }
710 }
711 }
712
713 (setup_lines, parts.join(", "))
714}
715
716fn is_numeric_expr(field_expr: &str) -> bool {
719 field_expr.starts_with("length(")
720}
721
722fn render_assertion(
723 out: &mut String,
724 assertion: &Assertion,
725 result_var: &str,
726 field_resolver: &FieldResolver,
727 module_path: &str,
728) {
729 if let Some(f) = &assertion.field {
732 match f.as_str() {
733 "chunks_have_content" => {
734 let pred =
735 format!("Enum.all?({result_var}.chunks || [], fn c -> c.content != nil and c.content != \"\" end)");
736 match assertion.assertion_type.as_str() {
737 "is_true" => {
738 let _ = writeln!(out, " assert {pred}");
739 }
740 "is_false" => {
741 let _ = writeln!(out, " refute {pred}");
742 }
743 _ => {
744 let _ = writeln!(
745 out,
746 " # skipped: unsupported assertion type on synthetic field '{f}'"
747 );
748 }
749 }
750 return;
751 }
752 "chunks_have_embeddings" => {
753 let pred = format!(
754 "Enum.all?({result_var}.chunks || [], fn c -> c.embedding != nil and c.embedding != [] end)"
755 );
756 match assertion.assertion_type.as_str() {
757 "is_true" => {
758 let _ = writeln!(out, " assert {pred}");
759 }
760 "is_false" => {
761 let _ = writeln!(out, " refute {pred}");
762 }
763 _ => {
764 let _ = writeln!(
765 out,
766 " # skipped: unsupported assertion type on synthetic field '{f}'"
767 );
768 }
769 }
770 return;
771 }
772 "embeddings" => {
776 match assertion.assertion_type.as_str() {
777 "count_equals" => {
778 if let Some(val) = &assertion.value {
779 let ex_val = json_to_elixir(val);
780 let _ = writeln!(out, " assert length({result_var}) == {ex_val}");
781 }
782 }
783 "count_min" => {
784 if let Some(val) = &assertion.value {
785 let ex_val = json_to_elixir(val);
786 let _ = writeln!(out, " assert length({result_var}) >= {ex_val}");
787 }
788 }
789 "not_empty" => {
790 let _ = writeln!(out, " assert {result_var} != []");
791 }
792 "is_empty" => {
793 let _ = writeln!(out, " assert {result_var} == []");
794 }
795 _ => {
796 let _ = writeln!(
797 out,
798 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
799 );
800 }
801 }
802 return;
803 }
804 "embedding_dimensions" => {
805 let expr = format!("(if {result_var} == [], do: 0, else: length(hd({result_var})))");
806 match assertion.assertion_type.as_str() {
807 "equals" => {
808 if let Some(val) = &assertion.value {
809 let ex_val = json_to_elixir(val);
810 let _ = writeln!(out, " assert {expr} == {ex_val}");
811 }
812 }
813 "greater_than" => {
814 if let Some(val) = &assertion.value {
815 let ex_val = json_to_elixir(val);
816 let _ = writeln!(out, " assert {expr} > {ex_val}");
817 }
818 }
819 _ => {
820 let _ = writeln!(
821 out,
822 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
823 );
824 }
825 }
826 return;
827 }
828 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
829 let pred = match f.as_str() {
830 "embeddings_valid" => {
831 format!("Enum.all?({result_var}, fn e -> e != [] end)")
832 }
833 "embeddings_finite" => {
834 format!("Enum.all?({result_var}, fn e -> Enum.all?(e, fn v -> is_float(v) and v == v end) end)")
835 }
836 "embeddings_non_zero" => {
837 format!("Enum.all?({result_var}, fn e -> Enum.any?(e, fn v -> v != 0.0 end) end)")
838 }
839 "embeddings_normalized" => {
840 format!(
841 "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)"
842 )
843 }
844 _ => unreachable!(),
845 };
846 match assertion.assertion_type.as_str() {
847 "is_true" => {
848 let _ = writeln!(out, " assert {pred}");
849 }
850 "is_false" => {
851 let _ = writeln!(out, " refute {pred}");
852 }
853 _ => {
854 let _ = writeln!(
855 out,
856 " # skipped: unsupported assertion type on synthetic field '{f}'"
857 );
858 }
859 }
860 return;
861 }
862 "keywords" | "keywords_count" => {
865 let _ = writeln!(
866 out,
867 " # skipped: field '{f}' not available on Elixir ExtractionResult"
868 );
869 return;
870 }
871 _ => {}
872 }
873 }
874
875 if let Some(f) = &assertion.field {
877 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
878 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
879 return;
880 }
881 }
882
883 let field_expr = match &assertion.field {
884 Some(f) if !f.is_empty() => field_resolver.accessor(f, "elixir", result_var),
885 _ => result_var.to_string(),
886 };
887
888 let is_numeric = is_numeric_expr(&field_expr);
891 let trimmed_field_expr = if is_numeric {
892 field_expr.clone()
893 } else {
894 format!("String.trim({field_expr})")
895 };
896
897 match assertion.assertion_type.as_str() {
898 "equals" => {
899 if let Some(expected) = &assertion.value {
900 let elixir_val = json_to_elixir(expected);
901 let is_string_expected = expected.is_string();
903 if is_string_expected && !is_numeric {
904 let _ = writeln!(out, " assert {trimmed_field_expr} == {elixir_val}");
905 } else {
906 let _ = writeln!(out, " assert {field_expr} == {elixir_val}");
907 }
908 }
909 }
910 "contains" => {
911 if let Some(expected) = &assertion.value {
912 let elixir_val = json_to_elixir(expected);
913 let _ = writeln!(
915 out,
916 " assert String.contains?(to_string({field_expr}), {elixir_val})"
917 );
918 }
919 }
920 "contains_all" => {
921 if let Some(values) = &assertion.values {
922 for val in values {
923 let elixir_val = json_to_elixir(val);
924 let _ = writeln!(
925 out,
926 " assert String.contains?(to_string({field_expr}), {elixir_val})"
927 );
928 }
929 }
930 }
931 "not_contains" => {
932 if let Some(expected) = &assertion.value {
933 let elixir_val = json_to_elixir(expected);
934 let _ = writeln!(
935 out,
936 " refute String.contains?(to_string({field_expr}), {elixir_val})"
937 );
938 }
939 }
940 "not_empty" => {
941 let _ = writeln!(out, " assert {field_expr} != \"\"");
942 }
943 "is_empty" => {
944 if is_numeric {
945 let _ = writeln!(out, " assert {field_expr} == 0");
947 } else {
948 let _ = writeln!(out, " assert is_nil({field_expr}) or {trimmed_field_expr} == \"\"");
950 }
951 }
952 "contains_any" => {
953 if let Some(values) = &assertion.values {
954 let items: Vec<String> = values.iter().map(json_to_elixir).collect();
955 let list_str = items.join(", ");
956 let _ = writeln!(
957 out,
958 " assert Enum.any?([{list_str}], fn v -> String.contains?(to_string({field_expr}), v) end)"
959 );
960 }
961 }
962 "greater_than" => {
963 if let Some(val) = &assertion.value {
964 let elixir_val = json_to_elixir(val);
965 let _ = writeln!(out, " assert {field_expr} > {elixir_val}");
966 }
967 }
968 "less_than" => {
969 if let Some(val) = &assertion.value {
970 let elixir_val = json_to_elixir(val);
971 let _ = writeln!(out, " assert {field_expr} < {elixir_val}");
972 }
973 }
974 "greater_than_or_equal" => {
975 if let Some(val) = &assertion.value {
976 let elixir_val = json_to_elixir(val);
977 let _ = writeln!(out, " assert {field_expr} >= {elixir_val}");
978 }
979 }
980 "less_than_or_equal" => {
981 if let Some(val) = &assertion.value {
982 let elixir_val = json_to_elixir(val);
983 let _ = writeln!(out, " assert {field_expr} <= {elixir_val}");
984 }
985 }
986 "starts_with" => {
987 if let Some(expected) = &assertion.value {
988 let elixir_val = json_to_elixir(expected);
989 let _ = writeln!(out, " assert String.starts_with?({field_expr}, {elixir_val})");
990 }
991 }
992 "ends_with" => {
993 if let Some(expected) = &assertion.value {
994 let elixir_val = json_to_elixir(expected);
995 let _ = writeln!(out, " assert String.ends_with?({field_expr}, {elixir_val})");
996 }
997 }
998 "min_length" => {
999 if let Some(val) = &assertion.value {
1000 if let Some(n) = val.as_u64() {
1001 let _ = writeln!(out, " assert String.length({field_expr}) >= {n}");
1002 }
1003 }
1004 }
1005 "max_length" => {
1006 if let Some(val) = &assertion.value {
1007 if let Some(n) = val.as_u64() {
1008 let _ = writeln!(out, " assert String.length({field_expr}) <= {n}");
1009 }
1010 }
1011 }
1012 "count_min" => {
1013 if let Some(val) = &assertion.value {
1014 if let Some(n) = val.as_u64() {
1015 let _ = writeln!(out, " assert length({field_expr}) >= {n}");
1016 }
1017 }
1018 }
1019 "count_equals" => {
1020 if let Some(val) = &assertion.value {
1021 if let Some(n) = val.as_u64() {
1022 let _ = writeln!(out, " assert length({field_expr}) == {n}");
1023 }
1024 }
1025 }
1026 "is_true" => {
1027 let _ = writeln!(out, " assert {field_expr} == true");
1028 }
1029 "is_false" => {
1030 let _ = writeln!(out, " assert {field_expr} == false");
1031 }
1032 "method_result" => {
1033 if let Some(method_name) = &assertion.method {
1034 let call_expr = build_elixir_method_call(result_var, method_name, assertion.args.as_ref(), module_path);
1035 let check = assertion.check.as_deref().unwrap_or("is_true");
1036 match check {
1037 "equals" => {
1038 if let Some(val) = &assertion.value {
1039 let elixir_val = json_to_elixir(val);
1040 let _ = writeln!(out, " assert {call_expr} == {elixir_val}");
1041 }
1042 }
1043 "is_true" => {
1044 let _ = writeln!(out, " assert {call_expr} == true");
1045 }
1046 "is_false" => {
1047 let _ = writeln!(out, " assert {call_expr} == false");
1048 }
1049 "greater_than_or_equal" => {
1050 if let Some(val) = &assertion.value {
1051 let n = val.as_u64().unwrap_or(0);
1052 let _ = writeln!(out, " assert {call_expr} >= {n}");
1053 }
1054 }
1055 "count_min" => {
1056 if let Some(val) = &assertion.value {
1057 let n = val.as_u64().unwrap_or(0);
1058 let _ = writeln!(out, " assert length({call_expr}) >= {n}");
1059 }
1060 }
1061 "contains" => {
1062 if let Some(val) = &assertion.value {
1063 let elixir_val = json_to_elixir(val);
1064 let _ = writeln!(out, " assert String.contains?({call_expr}, {elixir_val})");
1065 }
1066 }
1067 "is_error" => {
1068 let _ = writeln!(out, " assert_raise RuntimeError, fn -> {call_expr} end");
1069 }
1070 other_check => {
1071 panic!("Elixir e2e generator: unsupported method_result check type: {other_check}");
1072 }
1073 }
1074 } else {
1075 panic!("Elixir e2e generator: method_result assertion missing 'method' field");
1076 }
1077 }
1078 "matches_regex" => {
1079 if let Some(expected) = &assertion.value {
1080 let elixir_val = json_to_elixir(expected);
1081 let _ = writeln!(out, " assert Regex.match?(~r/{elixir_val}/, {field_expr})");
1082 }
1083 }
1084 "not_error" => {
1085 }
1087 "error" => {
1088 }
1090 other => {
1091 panic!("Elixir e2e generator: unsupported assertion type: {other}");
1092 }
1093 }
1094}
1095
1096fn build_elixir_method_call(
1099 result_var: &str,
1100 method_name: &str,
1101 args: Option<&serde_json::Value>,
1102 module_path: &str,
1103) -> String {
1104 match method_name {
1105 "root_child_count" => format!("{module_path}.root_child_count({result_var})"),
1106 "has_error_nodes" => format!("{module_path}.tree_has_error_nodes({result_var})"),
1107 "error_count" | "tree_error_count" => format!("{module_path}.tree_error_count({result_var})"),
1108 "tree_to_sexp" => format!("{module_path}.tree_to_sexp({result_var})"),
1109 "contains_node_type" => {
1110 let node_type = args
1111 .and_then(|a| a.get("node_type"))
1112 .and_then(|v| v.as_str())
1113 .unwrap_or("");
1114 format!("{module_path}.tree_contains_node_type({result_var}, \"{node_type}\")")
1115 }
1116 "find_nodes_by_type" => {
1117 let node_type = args
1118 .and_then(|a| a.get("node_type"))
1119 .and_then(|v| v.as_str())
1120 .unwrap_or("");
1121 format!("{module_path}.find_nodes_by_type({result_var}, \"{node_type}\")")
1122 }
1123 "run_query" => {
1124 let query_source = args
1125 .and_then(|a| a.get("query_source"))
1126 .and_then(|v| v.as_str())
1127 .unwrap_or("");
1128 let language = args
1129 .and_then(|a| a.get("language"))
1130 .and_then(|v| v.as_str())
1131 .unwrap_or("");
1132 format!("{module_path}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1133 }
1134 _ => format!("{module_path}.{method_name}({result_var})"),
1135 }
1136}
1137
1138fn elixir_module_name(category: &str) -> String {
1140 use heck::ToUpperCamelCase;
1141 category.to_upper_camel_case()
1142}
1143
1144fn json_to_elixir(value: &serde_json::Value) -> String {
1146 match value {
1147 serde_json::Value::String(s) => format!("\"{}\"", escape_elixir(s)),
1148 serde_json::Value::Bool(true) => "true".to_string(),
1149 serde_json::Value::Bool(false) => "false".to_string(),
1150 serde_json::Value::Number(n) => {
1151 let s = n.to_string().replace("e+", "e");
1155 if s.contains('e') && !s.contains('.') {
1156 s.replacen('e', ".0e", 1)
1158 } else {
1159 s
1160 }
1161 }
1162 serde_json::Value::Null => "nil".to_string(),
1163 serde_json::Value::Array(arr) => {
1164 let items: Vec<String> = arr.iter().map(json_to_elixir).collect();
1165 format!("[{}]", items.join(", "))
1166 }
1167 serde_json::Value::Object(map) => {
1168 let entries: Vec<String> = map
1169 .iter()
1170 .map(|(k, v)| format!("\"{}\" => {}", escape_elixir(k), json_to_elixir(v)))
1171 .collect();
1172 format!("%{{{}}}", entries.join(", "))
1173 }
1174 }
1175}
1176
1177fn build_elixir_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1179 use std::fmt::Write as FmtWrite;
1180 let mut visitor_obj = String::new();
1181 let _ = writeln!(visitor_obj, "%{{");
1182 for (method_name, action) in &visitor_spec.callbacks {
1183 emit_elixir_visitor_method(&mut visitor_obj, method_name, action);
1184 }
1185 let _ = writeln!(visitor_obj, " }}");
1186
1187 setup_lines.push(format!("visitor = {visitor_obj}"));
1188 "visitor".to_string()
1189}
1190
1191fn emit_elixir_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1193 use std::fmt::Write as FmtWrite;
1194
1195 let handle_method = format!("handle_{}", &method_name[6..]); let params = match method_name {
1198 "visit_link" => "_ctx, _href, _text, _title",
1199 "visit_image" => "_ctx, _src, _alt, _title",
1200 "visit_heading" => "_ctx, _level, text, _id",
1201 "visit_code_block" => "_ctx, _lang, _code",
1202 "visit_code_inline"
1203 | "visit_strong"
1204 | "visit_emphasis"
1205 | "visit_strikethrough"
1206 | "visit_underline"
1207 | "visit_subscript"
1208 | "visit_superscript"
1209 | "visit_mark"
1210 | "visit_button"
1211 | "visit_summary"
1212 | "visit_figcaption"
1213 | "visit_definition_term"
1214 | "visit_definition_description" => "_ctx, _text",
1215 "visit_text" => "_ctx, _text",
1216 "visit_list_item" => "_ctx, _ordered, _marker, _text",
1217 "visit_blockquote" => "_ctx, _content, _depth",
1218 "visit_table_row" => "_ctx, _cells, _is_header",
1219 "visit_custom_element" => "_ctx, _tag_name, _html",
1220 "visit_form" => "_ctx, _action_url, _method",
1221 "visit_input" => "_ctx, _input_type, _name, _value",
1222 "visit_audio" | "visit_video" | "visit_iframe" => "_ctx, _src",
1223 "visit_details" => "_ctx, _is_open",
1224 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "_ctx, _output",
1225 "visit_list_start" => "_ctx, _ordered",
1226 "visit_list_end" => "_ctx, _ordered, _output",
1227 _ => "_ctx",
1228 };
1229
1230 let _ = writeln!(out, " :{handle_method} => fn({params}) ->");
1231 match action {
1232 CallbackAction::Skip => {
1233 let _ = writeln!(out, " :skip");
1234 }
1235 CallbackAction::Continue => {
1236 let _ = writeln!(out, " :continue");
1237 }
1238 CallbackAction::PreserveHtml => {
1239 let _ = writeln!(out, " :preserve_html");
1240 }
1241 CallbackAction::Custom { output } => {
1242 let escaped = escape_elixir(output);
1243 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1244 }
1245 CallbackAction::CustomTemplate { template } => {
1246 let escaped = escape_elixir(template);
1248 let _ = writeln!(out, " {{:custom, \"{escaped}\"}}");
1249 }
1250 }
1251 let _ = writeln!(out, " end,");
1252}