1use crate::config::E2eConfig;
4use crate::escape::{escape_r, r_template_to_paste0, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, TemplateReturnForm};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::ResolvedCrateConfig;
9use alef_core::hash::{self, CommentStyle};
10use anyhow::Result;
11use std::fmt::Write as FmtWrite;
12use std::path::PathBuf;
13
14use super::E2eCodegen;
15
16pub struct RCodegen;
18
19impl E2eCodegen for RCodegen {
20 fn generate(
21 &self,
22 groups: &[FixtureGroup],
23 e2e_config: &E2eConfig,
24 config: &ResolvedCrateConfig,
25 _type_defs: &[alef_core::ir::TypeDef],
26 _enums: &[alef_core::ir::EnumDef],
27 ) -> Result<Vec<GeneratedFile>> {
28 let lang = self.language_name();
29 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
30
31 let mut files = Vec::new();
32
33 let call = &e2e_config.call;
35 let overrides = call.overrides.get(lang);
36 let module_path = overrides
37 .and_then(|o| o.module.as_ref())
38 .cloned()
39 .unwrap_or_else(|| call.module.clone());
40 let _function_name = overrides
41 .and_then(|o| o.function.as_ref())
42 .cloned()
43 .unwrap_or_else(|| call.function.clone());
44 let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
45 let result_is_r_list = overrides.is_some_and(|o| o.result_is_r_list);
46 let _result_var = &call.result_var;
47
48 let r_pkg = e2e_config.resolve_package("r");
50 let pkg_name = r_pkg
51 .as_ref()
52 .and_then(|p| p.name.as_ref())
53 .cloned()
54 .unwrap_or_else(|| module_path.clone());
55 let pkg_path = r_pkg
56 .as_ref()
57 .and_then(|p| p.path.as_ref())
58 .cloned()
59 .unwrap_or_else(|| "../../packages/r".to_string());
60 let pkg_version = r_pkg
61 .as_ref()
62 .and_then(|p| p.version.as_ref())
63 .cloned()
64 .or_else(|| config.resolved_version())
65 .unwrap_or_else(|| "0.1.0".to_string());
66
67 files.push(GeneratedFile {
69 path: output_base.join("DESCRIPTION"),
70 content: render_description(&pkg_name, &pkg_version, e2e_config.dep_mode),
71 generated_header: false,
72 });
73
74 files.push(GeneratedFile {
76 path: output_base.join("run_tests.R"),
77 content: render_test_runner(&pkg_path, e2e_config.dep_mode),
78 generated_header: true,
79 });
80
81 files.push(GeneratedFile {
87 path: output_base.join("tests").join("setup-fixtures.R"),
88 content: render_setup_fixtures(&e2e_config.test_documents_relative_from(1)),
89 generated_header: true,
90 });
91
92 for group in groups {
94 let active: Vec<&Fixture> = group
95 .fixtures
96 .iter()
97 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
98 .collect();
99
100 if active.is_empty() {
101 continue;
102 }
103
104 let filename = format!("test_{}.R", sanitize_filename(&group.category));
105 let content = render_test_file(&group.category, &active, result_is_simple, result_is_r_list, e2e_config);
106 files.push(GeneratedFile {
107 path: output_base.join("tests").join(filename),
108 content,
109 generated_header: true,
110 });
111 }
112
113 Ok(files)
114 }
115
116 fn language_name(&self) -> &'static str {
117 "r"
118 }
119}
120
121fn render_description(pkg_name: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
122 let dep_line = match dep_mode {
123 crate::config::DependencyMode::Registry => {
124 format!("Imports: {pkg_name} ({pkg_version})\n")
125 }
126 crate::config::DependencyMode::Local => String::new(),
127 };
128 format!(
129 r#"Package: e2e.r
130Title: E2E Tests for {pkg_name}
131Version: 0.1.0
132Description: End-to-end test suite.
133{dep_line}Suggests: testthat (>= 3.0.0)
134Config/testthat/edition: 3
135"#
136 )
137}
138
139fn render_setup_fixtures(test_documents_path: &str) -> String {
140 let mut out = String::new();
141 out.push_str(&hash::header(CommentStyle::Hash));
142 let _ = writeln!(out);
143 let _ = writeln!(
144 out,
145 "# Resolve fixture paths against the repo's `test_documents/` directory."
146 );
147 let _ = writeln!(
148 out,
149 "# testthat sources setup-*.R with the working directory at tests/,"
150 );
151 let _ = writeln!(
152 out,
153 "# so test_documents lives three directories up: tests/ -> e2e/r/ -> e2e/ -> repo root."
154 );
155 let _ = writeln!(
156 out,
157 "# Each `test_that()` block has its working directory reset back to tests/, so"
158 );
159 let _ = writeln!(
160 out,
161 "# fixture lookups must be performed via this helper rather than relying on `setwd`."
162 );
163 let _ = writeln!(
164 out,
165 ".alef_test_documents <- normalizePath(\"{test_documents_path}\", mustWork = FALSE)"
166 );
167 let _ = writeln!(out, ".resolve_fixture <- function(path) {{");
168 let _ = writeln!(out, " if (dir.exists(.alef_test_documents)) {{");
169 let _ = writeln!(out, " file.path(.alef_test_documents, path)");
170 let _ = writeln!(out, " }} else {{");
171 let _ = writeln!(out, " path");
172 let _ = writeln!(out, " }}");
173 let _ = writeln!(out, "}}");
174 out
175}
176
177fn render_test_runner(pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
178 let mut out = String::new();
179 out.push_str(&hash::header(CommentStyle::Hash));
180 let _ = writeln!(out, "library(testthat)");
181 match dep_mode {
182 crate::config::DependencyMode::Registry => {
183 let _ = writeln!(out, "# Package loaded via library() from CRAN install.");
185 }
186 crate::config::DependencyMode::Local => {
187 let _ = writeln!(out, "devtools::load_all(\"{pkg_path}\")");
190 }
191 }
192 let _ = writeln!(out);
193 let _ = writeln!(out, "testthat::set_max_fails(Inf)");
196 let _ = writeln!(
200 out,
201 ".script_dir <- tryCatch(dirname(normalizePath(sys.frame(1)$ofile)), error = function(e) getwd())"
202 );
203 let _ = writeln!(out, "test_dir(file.path(.script_dir, \"tests\"))");
204 out
205}
206
207fn render_test_file(
208 category: &str,
209 fixtures: &[&Fixture],
210 result_is_simple: bool,
211 result_is_r_list: bool,
212 e2e_config: &E2eConfig,
213) -> String {
214 let mut out = String::new();
215 out.push_str(&hash::header(CommentStyle::Hash));
216 let _ = writeln!(out, "# E2e tests for category: {category}");
217 let _ = writeln!(out);
218
219 for (i, fixture) in fixtures.iter().enumerate() {
220 render_test_case(&mut out, fixture, e2e_config, result_is_simple, result_is_r_list);
221 if i + 1 < fixtures.len() {
222 let _ = writeln!(out);
223 }
224 }
225
226 while out.ends_with("\n\n") {
228 out.pop();
229 }
230 if !out.ends_with('\n') {
231 out.push('\n');
232 }
233 out
234}
235
236fn render_test_case(
237 out: &mut String,
238 fixture: &Fixture,
239 e2e_config: &E2eConfig,
240 default_result_is_simple: bool,
241 default_result_is_r_list: bool,
242) {
243 let call_config = e2e_config.resolve_call_for_fixture(
244 fixture.call.as_deref(),
245 &fixture.id,
246 &fixture.resolved_category(),
247 &fixture.tags,
248 &fixture.input,
249 );
250 let call_field_resolver = FieldResolver::new(
251 e2e_config.effective_fields(call_config),
252 e2e_config.effective_fields_optional(call_config),
253 e2e_config.effective_result_fields(call_config),
254 e2e_config.effective_fields_array(call_config),
255 &std::collections::HashSet::new(),
256 );
257 let field_resolver = &call_field_resolver;
258 let function_name = call_config
264 .overrides
265 .get("r")
266 .and_then(|o| o.function.as_ref())
267 .cloned()
268 .unwrap_or_else(|| call_config.function.clone());
269 let result_var = &call_config.result_var;
270 let r_override = call_config.overrides.get("r");
276 let result_is_simple = if fixture.call.is_some() {
277 call_config.result_is_simple || r_override.is_some_and(|o| o.result_is_simple)
278 } else {
279 default_result_is_simple
280 };
281 let result_is_r_list = if fixture.call.is_some() {
285 r_override.is_some_and(|o| o.result_is_r_list)
286 } else {
287 default_result_is_r_list
288 };
289
290 let test_name = sanitize_ident(&fixture.id);
291 let description = fixture.description.replace('"', "\\\"");
292
293 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
294
295 let arg_name_map = r_override.map(|o| &o.arg_name_map);
300 let options_type = r_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
306 call_config
315 .overrides
316 .values()
317 .filter_map(|o| o.options_type.as_deref())
318 .find(|name| !name.starts_with("Js"))
319 });
320 let args_str = build_args_string(&fixture.input, &call_config.args, arg_name_map, options_type);
321
322 let mut setup_lines = Vec::new();
324 let final_args = if let Some(visitor_spec) = &fixture.visitor {
325 build_r_visitor(&mut setup_lines, visitor_spec);
326 let base = strip_options_arg(&args_str);
331 let visitor_opts = "options = list(visitor = visitor)";
332 let trimmed = base.trim_matches([' ', ',']);
333 if trimmed.is_empty() {
334 visitor_opts.to_string()
335 } else {
336 format!("{trimmed}, {visitor_opts}")
337 }
338 } else {
339 args_str
340 };
341
342 if expects_error {
343 let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
344 for line in &setup_lines {
345 let _ = writeln!(out, " {line}");
346 }
347 let _ = writeln!(out, " expect_error({function_name}({final_args}))");
348 let _ = writeln!(out, "}})");
349 return;
350 }
351
352 let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
353 for line in &setup_lines {
354 let _ = writeln!(out, " {line}");
355 }
356 if call_config.returns_void {
368 let _ = writeln!(out, " invisible({function_name}({final_args}))");
369 } else if result_is_simple || result_is_r_list {
370 let _ = writeln!(out, " {result_var} <- {function_name}({final_args})");
371 } else {
372 let _ = writeln!(
373 out,
374 " {result_var} <- jsonlite::fromJSON({function_name}({final_args}), simplifyVector = FALSE)"
375 );
376 }
377
378 for assertion in &fixture.assertions {
379 render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
380 }
381
382 let _ = writeln!(out, "}})");
383}
384
385fn strip_options_arg(args_str: &str) -> String {
392 let mut parts: Vec<String> = Vec::new();
393 let mut current = String::new();
394 let mut paren_depth: i32 = 0;
395 let mut in_single = false;
396 let mut in_double = false;
397 for c in args_str.chars() {
398 if !in_single && !in_double {
399 match c {
400 '(' | '[' | '{' => paren_depth += 1,
401 ')' | ']' | '}' => paren_depth -= 1,
402 '\'' => in_single = true,
403 '"' => in_double = true,
404 ',' if paren_depth == 0 => {
405 parts.push(current.trim().to_string());
406 current.clear();
407 continue;
408 }
409 _ => {}
410 }
411 } else if in_single && c == '\'' {
412 in_single = false;
413 } else if in_double && c == '"' {
414 in_double = false;
415 }
416 current.push(c);
417 }
418 if !current.trim().is_empty() {
419 parts.push(current.trim().to_string());
420 }
421 parts
422 .into_iter()
423 .filter(|p| !p.starts_with("options ") && !p.starts_with("options="))
424 .collect::<Vec<_>>()
425 .join(", ")
426}
427
428fn build_args_string(
429 input: &serde_json::Value,
430 args: &[crate::config::ArgMapping],
431 arg_name_map: Option<&std::collections::HashMap<String, String>>,
432 options_type: Option<&str>,
433) -> String {
434 if args.is_empty() {
435 if matches!(input, serde_json::Value::Null) || input.as_object().is_some_and(|m| m.is_empty()) {
439 return String::new();
440 }
441 return json_to_r(input, true);
442 }
443
444 let parts: Vec<String> = args
445 .iter()
446 .filter_map(|arg| {
447 let arg_name: &str = arg_name_map
449 .and_then(|m| m.get(&arg.name).map(String::as_str))
450 .unwrap_or(&arg.name);
451
452 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
453 let val = input.get(field);
454 let val = match val {
460 Some(v) if !(v.is_null() && arg.optional) => v,
461 _ => {
462 if !arg.optional {
463 return None;
464 }
465 if arg.arg_type == "json_object" {
466 let r_value = r_default_for_config_arg(arg_name, options_type);
467 return Some(format!("{arg_name} = {r_value}"));
468 }
469 return Some(format!("{arg_name} = NULL"));
470 }
471 };
472 if arg.arg_type == "json_object" && (val.is_null() || val.as_object().is_some_and(|m| m.is_empty())) {
479 let r_value = r_default_for_config_arg(arg_name, options_type);
480 return Some(format!("{arg_name} = {r_value}"));
481 }
482 if arg.arg_type == "json_object" && val.is_object() {
487 let default_expr = r_default_for_config_arg(arg_name, options_type);
488 if default_expr.ends_with("$default()") {
489 let type_name = default_expr.trim_end_matches("$default()");
491 let r_list = json_to_r_preserve_arrays(val, true);
497 let r_value = format!("{type_name}$from_json(jsonlite::toJSON({r_list}, auto_unbox = TRUE))");
498 return Some(format!("{arg_name} = {r_value}"));
499 }
500 let r_value = json_to_r(val, true);
501 return Some(format!("{arg_name} = {r_value}"));
502 }
503 if arg.arg_type == "json_object" && val.is_array() {
515 if arg.element_type.as_deref() == Some("String") {
516 let r_value = json_to_r(val, false);
517 return Some(format!("{arg_name} = {r_value}"));
518 }
519 let json_literal = serde_json::to_string(val).unwrap_or_else(|_| "[]".to_string());
520 let escaped = escape_r(&json_literal);
521 return Some(format!("{arg_name} = \"{escaped}\""));
522 }
523 if arg.arg_type == "bytes" {
528 if let Some(raw) = val.as_str() {
529 let r_value = render_bytes_value(raw);
530 return Some(format!("{arg_name} = {r_value}"));
531 }
532 }
533 if arg.arg_type == "file_path" {
538 if let Some(raw) = val.as_str() {
539 if !raw.starts_with('/') && !raw.is_empty() {
540 let escaped = escape_r(raw);
541 return Some(format!("{arg_name} = .resolve_fixture(\"{escaped}\")"));
542 }
543 }
544 }
545 Some(format!("{arg_name} = {}", json_to_r(val, true)))
546 })
547 .collect();
548
549 parts.join(", ")
550}
551
552fn render_bytes_value(raw: &str) -> String {
558 if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
559 let escaped = escape_r(raw);
561 return format!("charToRaw(\"{escaped}\")");
562 }
563 let first = raw.chars().next().unwrap_or('\0');
564 if first.is_ascii_alphanumeric() || first == '_' {
565 if let Some(slash) = raw.find('/') {
566 if slash > 0 {
567 let after = &raw[slash + 1..];
568 if after.contains('.') && !after.is_empty() {
569 let escaped = escape_r(raw);
570 return format!(
571 "readBin(.resolve_fixture(\"{escaped}\"), what = \"raw\", n = file.info(.resolve_fixture(\"{escaped}\"))$size)"
572 );
573 }
574 }
575 }
576 }
577 let escaped = escape_r(raw);
579 format!("charToRaw(\"{escaped}\")")
580}
581
582fn r_default_for_config_arg(arg_name: &str, options_type: Option<&str>) -> String {
591 if let Some(type_name) = options_type {
592 return format!("{type_name}$default()");
593 }
594 match arg_name {
595 "config" => "ExtractionConfig$default()".to_string(),
596 "options" => "NULL".to_string(),
597 "html_output" => "HtmlOutputConfig$default()".to_string(),
598 "chunking" => "ChunkingConfig$default()".to_string(),
599 "ocr" => "OcrConfig$default()".to_string(),
600 "image" | "images" => "ImageExtractionConfig$default()".to_string(),
601 "language_detection" => "LanguageDetectionConfig$default()".to_string(),
602 _ => "list()".to_string(),
603 }
604}
605
606fn render_assertion(
607 out: &mut String,
608 assertion: &Assertion,
609 result_var: &str,
610 field_resolver: &FieldResolver,
611 result_is_simple: bool,
612 _e2e_config: &E2eConfig,
613) {
614 if let Some(f) = &assertion.field {
617 match f.as_str() {
618 "chunks_have_content" => {
619 let pred = format!("all(sapply({result_var}$chunks %||% list(), function(c) nchar(c$content) > 0))");
620 match assertion.assertion_type.as_str() {
621 "is_true" => {
622 let _ = writeln!(out, " expect_true({pred})");
623 }
624 "is_false" => {
625 let _ = writeln!(out, " expect_false({pred})");
626 }
627 _ => {
628 let _ = writeln!(out, " # skipped: unsupported assertion type on synthetic field '{f}'");
629 }
630 }
631 return;
632 }
633 "chunks_have_embeddings" => {
634 let pred = format!(
635 "all(sapply({result_var}$chunks %||% list(), function(c) !is.null(c$embedding) && length(c$embedding) > 0))"
636 );
637 match assertion.assertion_type.as_str() {
638 "is_true" => {
639 let _ = writeln!(out, " expect_true({pred})");
640 }
641 "is_false" => {
642 let _ = writeln!(out, " expect_false({pred})");
643 }
644 _ => {
645 let _ = writeln!(out, " # skipped: unsupported assertion type on synthetic field '{f}'");
646 }
647 }
648 return;
649 }
650 "chunks_have_heading_context" => {
651 let pred_true = format!(
654 "!is.null({result_var}$chunks) && length({result_var}$chunks) > 0 && all(sapply({result_var}$chunks, function(c) nchar(c$content) > 0))"
655 );
656 let pred_false = format!("is.null({result_var}$chunks) || length({result_var}$chunks) == 0");
657 match assertion.assertion_type.as_str() {
658 "is_true" => {
659 let _ = writeln!(out, " expect_true({pred_true})");
660 }
661 "is_false" => {
662 let _ = writeln!(out, " expect_true({pred_false})");
663 }
664 _ => {
665 let _ = writeln!(out, " # skipped: unsupported assertion type on synthetic field '{f}'");
666 }
667 }
668 return;
669 }
670 "first_chunk_starts_with_heading" => {
671 let pred_true = format!(
674 "!is.null({result_var}$chunks) && length({result_var}$chunks) > 0 && startsWith(trimws({result_var}$chunks[[1]]$content), \"#\")"
675 );
676 let pred_false = format!(
677 "is.null({result_var}$chunks) || length({result_var}$chunks) == 0 || !startsWith(trimws({result_var}$chunks[[1]]$content), \"#\")"
678 );
679 match assertion.assertion_type.as_str() {
680 "is_true" => {
681 let _ = writeln!(out, " expect_true({pred_true})");
682 }
683 "is_false" => {
684 let _ = writeln!(out, " expect_true({pred_false})");
685 }
686 _ => {
687 let _ = writeln!(out, " # skipped: unsupported assertion type on synthetic field '{f}'");
688 }
689 }
690 return;
691 }
692 "embeddings" => {
699 let parsed = format!(
700 "(if (is.character({result_var}) && length({result_var}) == 1) jsonlite::fromJSON({result_var}, simplifyVector = FALSE) else {result_var})"
701 );
702 match assertion.assertion_type.as_str() {
703 "count_equals" => {
704 if let Some(val) = &assertion.value {
705 let r_val = json_to_r(val, false);
706 let _ = writeln!(out, " expect_equal(length({parsed}), {r_val})");
707 }
708 }
709 "count_min" => {
710 if let Some(val) = &assertion.value {
711 let r_val = json_to_r(val, false);
712 let _ = writeln!(out, " expect_gte(length({parsed}), {r_val})");
713 }
714 }
715 "not_empty" => {
716 let _ = writeln!(out, " expect_gt(length({parsed}), 0)");
717 }
718 "is_empty" => {
719 let _ = writeln!(out, " expect_equal(length({parsed}), 0)");
720 }
721 _ => {
722 let _ = writeln!(
723 out,
724 " # skipped: unsupported assertion type on synthetic field 'embeddings'"
725 );
726 }
727 }
728 return;
729 }
730 "embedding_dimensions" => {
731 let expr = format!("(if (length({result_var}) == 0) 0L else length({result_var}[[1]]))");
732 match assertion.assertion_type.as_str() {
733 "equals" => {
734 if let Some(val) = &assertion.value {
735 let r_val = json_to_r(val, false);
736 let _ = writeln!(out, " expect_equal({expr}, {r_val})");
737 }
738 }
739 "greater_than" => {
740 if let Some(val) = &assertion.value {
741 let r_val = json_to_r(val, false);
742 let _ = writeln!(out, " expect_gt({expr}, {r_val})");
743 }
744 }
745 _ => {
746 let _ = writeln!(
747 out,
748 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
749 );
750 }
751 }
752 return;
753 }
754 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
755 let pred = match f.as_str() {
756 "embeddings_valid" => {
757 format!("all(sapply({result_var}, function(e) length(e) > 0))")
758 }
759 "embeddings_finite" => {
760 format!("all(sapply({result_var}, function(e) all(is.finite(e))))")
761 }
762 "embeddings_non_zero" => {
763 format!("all(sapply({result_var}, function(e) any(e != 0.0)))")
764 }
765 "embeddings_normalized" => {
766 format!("all(sapply({result_var}, function(e) abs(sum(e * e) - 1.0) < 1e-3))")
767 }
768 _ => unreachable!(),
769 };
770 match assertion.assertion_type.as_str() {
771 "is_true" => {
772 let _ = writeln!(out, " expect_true({pred})");
773 }
774 "is_false" => {
775 let _ = writeln!(out, " expect_false({pred})");
776 }
777 _ => {
778 let _ = writeln!(out, " # skipped: unsupported assertion type on synthetic field '{f}'");
779 }
780 }
781 return;
782 }
783 "keywords" | "keywords_count" => {
786 let _ = writeln!(out, " # skipped: field '{f}' not available on R ExtractionResult");
787 return;
788 }
789 _ => {}
790 }
791 }
792
793 if let Some(f) = &assertion.field {
795 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
796 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
797 return;
798 }
799 }
800
801 if result_is_simple {
804 if let Some(f) = &assertion.field {
805 let f_lower = f.to_lowercase();
806 if !f.is_empty()
807 && f_lower != "content"
808 && (f_lower.starts_with("metadata")
809 || f_lower.starts_with("document")
810 || f_lower.starts_with("structure"))
811 {
812 let _ = writeln!(
813 out,
814 " # skipped: result_is_simple for field '{f}' not available on result type"
815 );
816 return;
817 }
818 }
819 }
820
821 let field_expr = if result_is_simple {
822 result_var.to_string()
823 } else {
824 match &assertion.field {
825 Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
826 _ => result_var.to_string(),
827 }
828 };
829
830 match assertion.assertion_type.as_str() {
831 "equals" => {
832 if let Some(expected) = &assertion.value {
833 let r_val = json_to_r(expected, false);
834 let _ = writeln!(out, " expect_equal(trimws({field_expr}), {r_val})");
835 }
836 }
837 "contains" => {
838 if let Some(expected) = &assertion.value {
839 let r_val = json_to_r(expected, false);
840 let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
841 }
842 }
843 "contains_all" => {
844 if let Some(values) = &assertion.values {
845 for val in values {
846 let r_val = json_to_r(val, false);
847 let _ = writeln!(out, " expect_true(any(grepl({r_val}, {field_expr}, fixed = TRUE)))");
848 }
849 }
850 }
851 "not_contains" => {
852 if let Some(expected) = &assertion.value {
853 let r_val = json_to_r(expected, false);
854 let _ = writeln!(out, " expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
855 }
856 }
857 "not_empty" => {
858 let _ = writeln!(
864 out,
865 " expect_true(if (is.character({field_expr})) length({field_expr}) > 0 && any(nchar({field_expr}) > 0) else length({field_expr}) > 0)"
866 );
867 }
868 "is_empty" => {
869 let _ = writeln!(
875 out,
876 " expect_true(is.null({field_expr}) || length({field_expr}) == 0 || (length({field_expr}) == 1 && (is.na({field_expr}) || identical({field_expr}, \"\"))))"
877 );
878 }
879 "contains_any" => {
880 if let Some(values) = &assertion.values {
881 let items: Vec<String> = values.iter().map(|v| json_to_r(v, false)).collect();
882 let vec_str = items.join(", ");
883 let _ = writeln!(
884 out,
885 " expect_true(any(sapply(c({vec_str}), function(v) grepl(v, {field_expr}, fixed = TRUE))))"
886 );
887 }
888 }
889 "greater_than" => {
890 if let Some(val) = &assertion.value {
891 let r_val = json_to_r(val, false);
892 let _ = writeln!(out, " expect_true({field_expr} > {r_val})");
893 }
894 }
895 "less_than" => {
896 if let Some(val) = &assertion.value {
897 let r_val = json_to_r(val, false);
898 let _ = writeln!(out, " expect_true({field_expr} < {r_val})");
899 }
900 }
901 "greater_than_or_equal" => {
902 if let Some(val) = &assertion.value {
903 let r_val = json_to_r(val, false);
904 let _ = writeln!(out, " expect_true({field_expr} >= {r_val})");
905 }
906 }
907 "less_than_or_equal" => {
908 if let Some(val) = &assertion.value {
909 let r_val = json_to_r(val, false);
910 let _ = writeln!(out, " expect_true({field_expr} <= {r_val})");
911 }
912 }
913 "starts_with" => {
914 if let Some(expected) = &assertion.value {
915 let r_val = json_to_r(expected, false);
916 let _ = writeln!(out, " expect_true(startsWith({field_expr}, {r_val}))");
917 }
918 }
919 "ends_with" => {
920 if let Some(expected) = &assertion.value {
921 let r_val = json_to_r(expected, false);
922 let _ = writeln!(out, " expect_true(endsWith({field_expr}, {r_val}))");
923 }
924 }
925 "min_length" => {
926 if let Some(val) = &assertion.value {
927 if let Some(n) = val.as_u64() {
928 let _ = writeln!(out, " expect_true(nchar({field_expr}) >= {n})");
929 }
930 }
931 }
932 "max_length" => {
933 if let Some(val) = &assertion.value {
934 if let Some(n) = val.as_u64() {
935 let _ = writeln!(out, " expect_true(nchar({field_expr}) <= {n})");
936 }
937 }
938 }
939 "count_min" => {
940 if let Some(val) = &assertion.value {
941 if let Some(n) = val.as_u64() {
942 let _ = writeln!(out, " expect_true(length({field_expr}) >= {n})");
943 }
944 }
945 }
946 "count_equals" => {
947 if let Some(val) = &assertion.value {
948 if let Some(n) = val.as_u64() {
949 let _ = writeln!(out, " expect_equal(length({field_expr}), {n})");
950 }
951 }
952 }
953 "is_true" => {
954 let _ = writeln!(out, " expect_true({field_expr})");
955 }
956 "is_false" => {
957 let _ = writeln!(out, " expect_false({field_expr})");
958 }
959 "method_result" => {
960 if let Some(method_name) = &assertion.method {
961 let call_expr = build_r_method_call(result_var, method_name, assertion.args.as_ref());
962 let check = assertion.check.as_deref().unwrap_or("is_true");
963 match check {
964 "equals" => {
965 if let Some(val) = &assertion.value {
966 if val.is_boolean() {
967 if val.as_bool() == Some(true) {
968 let _ = writeln!(out, " expect_true({call_expr})");
969 } else {
970 let _ = writeln!(out, " expect_false({call_expr})");
971 }
972 } else {
973 let r_val = json_to_r(val, false);
974 let _ = writeln!(out, " expect_equal({call_expr}, {r_val})");
975 }
976 }
977 }
978 "is_true" => {
979 let _ = writeln!(out, " expect_true({call_expr})");
980 }
981 "is_false" => {
982 let _ = writeln!(out, " expect_false({call_expr})");
983 }
984 "greater_than_or_equal" => {
985 if let Some(val) = &assertion.value {
986 let r_val = json_to_r(val, false);
987 let _ = writeln!(out, " expect_true({call_expr} >= {r_val})");
988 }
989 }
990 "count_min" => {
991 if let Some(val) = &assertion.value {
992 let n = val.as_u64().unwrap_or(0);
993 let _ = writeln!(out, " expect_true(length({call_expr}) >= {n})");
994 }
995 }
996 "is_error" => {
997 let _ = writeln!(out, " expect_error({call_expr})");
998 }
999 "contains" => {
1000 if let Some(val) = &assertion.value {
1001 let r_val = json_to_r(val, false);
1002 let _ = writeln!(out, " expect_true(grepl({r_val}, {call_expr}, fixed = TRUE))");
1003 }
1004 }
1005 other_check => {
1006 panic!("R e2e generator: unsupported method_result check type: {other_check}");
1007 }
1008 }
1009 } else {
1010 panic!("R e2e generator: method_result assertion missing 'method' field");
1011 }
1012 }
1013 "matches_regex" => {
1014 if let Some(expected) = &assertion.value {
1015 let r_val = json_to_r(expected, false);
1016 let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}))");
1017 }
1018 }
1019 "not_error" => {
1020 let _ = writeln!(out, " expect_true(TRUE)");
1024 }
1025 "error" => {
1026 }
1028 other => {
1029 panic!("R e2e generator: unsupported assertion type: {other}");
1030 }
1031 }
1032}
1033
1034fn pascal_to_snake_case(s: &str) -> String {
1043 let mut result = String::with_capacity(s.len() + 4);
1044 for (i, ch) in s.chars().enumerate() {
1045 if ch.is_uppercase() && i > 0 {
1046 result.push('_');
1047 }
1048 for lc in ch.to_lowercase() {
1049 result.push(lc);
1050 }
1051 }
1052 result
1053}
1054
1055fn json_to_r_preserve_arrays(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
1066 match value {
1067 serde_json::Value::Array(arr) => {
1068 if arr.is_empty() {
1069 "I(list())".to_string()
1070 } else {
1071 let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
1072 format!("I(c({}))", items.join(", "))
1073 }
1074 }
1075 serde_json::Value::Object(map) => {
1076 let entries: Vec<String> = map
1077 .iter()
1078 .map(|(k, v)| {
1079 format!(
1080 "\"{}\" = {}",
1081 escape_r(k),
1082 json_to_r_preserve_arrays(v, lowercase_enum_values)
1083 )
1084 })
1085 .collect();
1086 format!("list({})", entries.join(", "))
1087 }
1088 _ => json_to_r(value, lowercase_enum_values),
1089 }
1090}
1091
1092fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
1095 match value {
1096 serde_json::Value::String(s) => {
1097 let normalized = if lowercase_enum_values && s.chars().next().is_some_and(|c| c.is_uppercase()) {
1100 pascal_to_snake_case(s)
1101 } else {
1102 s.clone()
1103 };
1104 format!("\"{}\"", escape_r(&normalized))
1105 }
1106 serde_json::Value::Bool(true) => "TRUE".to_string(),
1107 serde_json::Value::Bool(false) => "FALSE".to_string(),
1108 serde_json::Value::Number(n) => n.to_string(),
1109 serde_json::Value::Null => "NULL".to_string(),
1110 serde_json::Value::Array(arr) => {
1111 let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
1112 format!("c({})", items.join(", "))
1113 }
1114 serde_json::Value::Object(map) => {
1115 let entries: Vec<String> = map
1116 .iter()
1117 .map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v, lowercase_enum_values)))
1118 .collect();
1119 format!("list({})", entries.join(", "))
1120 }
1121 }
1122}
1123
1124fn build_r_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
1126 use std::fmt::Write as FmtWrite;
1127 let methods: Vec<String> = visitor_spec
1130 .callbacks
1131 .iter()
1132 .map(|(method_name, action)| {
1133 let mut buf = String::new();
1134 emit_r_visitor_method(&mut buf, method_name, action);
1135 buf.trim_end_matches(['\n', ',']).to_string()
1137 })
1138 .collect();
1139 let mut visitor_obj = String::new();
1140 let _ = writeln!(visitor_obj, "list(");
1141 let _ = write!(visitor_obj, "{}", methods.join(",\n"));
1142 let _ = writeln!(visitor_obj);
1143 let _ = writeln!(visitor_obj, " )");
1144
1145 setup_lines.push(format!("visitor <- {visitor_obj}"));
1146}
1147
1148fn build_r_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1151 match method_name {
1152 "root_child_count" => format!("{result_var}$root_child_count()"),
1153 "root_node_type" => format!("{result_var}$root_node_type()"),
1154 "named_children_count" => format!("{result_var}$named_children_count()"),
1155 "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
1156 "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
1157 "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
1158 "contains_node_type" => {
1159 let node_type = args
1160 .and_then(|a| a.get("node_type"))
1161 .and_then(|v| v.as_str())
1162 .unwrap_or("");
1163 format!("tree_contains_node_type({result_var}, \"{node_type}\")")
1164 }
1165 "find_nodes_by_type" => {
1166 let node_type = args
1167 .and_then(|a| a.get("node_type"))
1168 .and_then(|v| v.as_str())
1169 .unwrap_or("");
1170 format!("find_nodes_by_type({result_var}, \"{node_type}\")")
1171 }
1172 "run_query" => {
1173 let query_source = args
1174 .and_then(|a| a.get("query_source"))
1175 .and_then(|v| v.as_str())
1176 .unwrap_or("");
1177 let language = args
1178 .and_then(|a| a.get("language"))
1179 .and_then(|v| v.as_str())
1180 .unwrap_or("");
1181 format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1182 }
1183 _ => {
1184 if let Some(args_val) = args {
1185 let arg_str = args_val
1186 .as_object()
1187 .map(|obj| {
1188 obj.iter()
1189 .map(|(k, v)| {
1190 let r_val = json_to_r(v, false);
1191 format!("{k} = {r_val}")
1192 })
1193 .collect::<Vec<_>>()
1194 .join(", ")
1195 })
1196 .unwrap_or_default();
1197 format!("{result_var}${method_name}({arg_str})")
1198 } else {
1199 format!("{result_var}${method_name}()")
1200 }
1201 }
1202 }
1203}
1204
1205fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1207 use std::fmt::Write as FmtWrite;
1208
1209 let params = match method_name {
1211 "visit_link" => "ctx, href, text, title",
1212 "visit_image" => "ctx, src, alt, title",
1213 "visit_heading" => "ctx, level, text, id",
1214 "visit_code_block" => "ctx, lang, code",
1215 "visit_code_inline"
1216 | "visit_strong"
1217 | "visit_emphasis"
1218 | "visit_strikethrough"
1219 | "visit_underline"
1220 | "visit_subscript"
1221 | "visit_superscript"
1222 | "visit_mark"
1223 | "visit_button"
1224 | "visit_summary"
1225 | "visit_figcaption"
1226 | "visit_definition_term"
1227 | "visit_definition_description" => "ctx, text",
1228 "visit_text" => "ctx, text",
1229 "visit_list_item" => "ctx, ordered, marker, text",
1230 "visit_blockquote" => "ctx, content, depth",
1231 "visit_table_row" => "ctx, cells, is_header",
1232 "visit_custom_element" => "ctx, tag_name, html",
1233 "visit_form" => "ctx, action_url, method",
1234 "visit_input" => "ctx, input_type, name, value",
1235 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1236 "visit_details" => "ctx, open",
1237 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1238 "visit_list_start" => "ctx, ordered",
1239 "visit_list_end" => "ctx, ordered, output",
1240 _ => "ctx",
1241 };
1242
1243 let _ = writeln!(out, " {method_name} = function({params}) {{");
1244 match action {
1245 CallbackAction::Skip => {
1246 let _ = writeln!(out, " \"skip\"");
1247 }
1248 CallbackAction::Continue => {
1249 let _ = writeln!(out, " \"continue\"");
1250 }
1251 CallbackAction::PreserveHtml => {
1252 let _ = writeln!(out, " \"preserve_html\"");
1253 }
1254 CallbackAction::Custom { output } => {
1255 let escaped = escape_r(output);
1256 let _ = writeln!(out, " list(custom = \"{escaped}\")");
1257 }
1258 CallbackAction::CustomTemplate { template, return_form } => {
1259 let r_expr = r_template_to_paste0(template);
1260 match return_form {
1261 TemplateReturnForm::BareString => {
1262 let _ = writeln!(out, " {r_expr}");
1263 }
1264 TemplateReturnForm::Dict => {
1265 let _ = writeln!(out, " list(custom = {r_expr})");
1266 }
1267 }
1268 }
1269 }
1270 let _ = writeln!(out, " }},");
1271}