1use crate::config::E2eConfig;
4use crate::escape::{escape_r, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::AlefConfig;
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 _alef_config: &AlefConfig,
25 ) -> Result<Vec<GeneratedFile>> {
26 let lang = self.language_name();
27 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
28
29 let mut files = Vec::new();
30
31 let call = &e2e_config.call;
33 let overrides = call.overrides.get(lang);
34 let module_path = overrides
35 .and_then(|o| o.module.as_ref())
36 .cloned()
37 .unwrap_or_else(|| call.module.clone());
38 let _function_name = overrides
39 .and_then(|o| o.function.as_ref())
40 .cloned()
41 .unwrap_or_else(|| call.function.clone());
42 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
43 let _result_var = &call.result_var;
44
45 let r_pkg = e2e_config.resolve_package("r");
47 let pkg_name = r_pkg
48 .as_ref()
49 .and_then(|p| p.name.as_ref())
50 .cloned()
51 .unwrap_or_else(|| module_path.clone());
52 let pkg_path = r_pkg
53 .as_ref()
54 .and_then(|p| p.path.as_ref())
55 .cloned()
56 .unwrap_or_else(|| "../../packages/r".to_string());
57 let pkg_version = r_pkg
58 .as_ref()
59 .and_then(|p| p.version.as_ref())
60 .cloned()
61 .unwrap_or_else(|| "0.1.0".to_string());
62
63 files.push(GeneratedFile {
65 path: output_base.join("DESCRIPTION"),
66 content: render_description(&pkg_name, &pkg_version, e2e_config.dep_mode),
67 generated_header: false,
68 });
69
70 files.push(GeneratedFile {
72 path: output_base.join("run_tests.R"),
73 content: render_test_runner(&pkg_path, e2e_config.dep_mode),
74 generated_header: true,
75 });
76
77 for group in groups {
79 let active: Vec<&Fixture> = group
80 .fixtures
81 .iter()
82 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
83 .collect();
84
85 if active.is_empty() {
86 continue;
87 }
88
89 let filename = format!("test_{}.R", sanitize_filename(&group.category));
90 let field_resolver = FieldResolver::new(
91 &e2e_config.fields,
92 &e2e_config.fields_optional,
93 &e2e_config.result_fields,
94 &e2e_config.fields_array,
95 );
96 let content = render_test_file(&group.category, &active, &field_resolver, result_is_simple, e2e_config);
97 files.push(GeneratedFile {
98 path: output_base.join("tests").join(filename),
99 content,
100 generated_header: true,
101 });
102 }
103
104 Ok(files)
105 }
106
107 fn language_name(&self) -> &'static str {
108 "r"
109 }
110}
111
112fn render_description(pkg_name: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
113 let dep_line = match dep_mode {
114 crate::config::DependencyMode::Registry => {
115 format!("Imports: {pkg_name} ({pkg_version})\n")
116 }
117 crate::config::DependencyMode::Local => String::new(),
118 };
119 format!(
120 r#"Package: e2e.r
121Title: E2E Tests for {pkg_name}
122Version: 0.1.0
123Description: End-to-end test suite.
124{dep_line}Suggests: testthat (>= 3.0.0)
125Config/testthat/edition: 3
126"#
127 )
128}
129
130fn render_test_runner(pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
131 let mut out = String::new();
132 out.push_str(&hash::header(CommentStyle::Hash));
133 let _ = writeln!(out, "library(testthat)");
134 match dep_mode {
135 crate::config::DependencyMode::Registry => {
136 let _ = writeln!(out, "# Package loaded via library() from CRAN install.");
138 }
139 crate::config::DependencyMode::Local => {
140 let _ = writeln!(out, "devtools::load_all(\"{pkg_path}\")");
143 }
144 }
145 let _ = writeln!(out);
146 let _ = writeln!(out, "test_dir(\"tests\")");
147 out
148}
149
150fn render_test_file(
151 category: &str,
152 fixtures: &[&Fixture],
153 field_resolver: &FieldResolver,
154 result_is_simple: bool,
155 e2e_config: &E2eConfig,
156) -> String {
157 let mut out = String::new();
158 out.push_str(&hash::header(CommentStyle::Hash));
159 let _ = writeln!(out, "# E2e tests for category: {category}");
160 let _ = writeln!(out);
161
162 for (i, fixture) in fixtures.iter().enumerate() {
163 render_test_case(&mut out, fixture, e2e_config, field_resolver, result_is_simple);
164 if i + 1 < fixtures.len() {
165 let _ = writeln!(out);
166 }
167 }
168
169 while out.ends_with("\n\n") {
171 out.pop();
172 }
173 if !out.ends_with('\n') {
174 out.push('\n');
175 }
176 out
177}
178
179fn render_test_case(
180 out: &mut String,
181 fixture: &Fixture,
182 e2e_config: &E2eConfig,
183 field_resolver: &FieldResolver,
184 result_is_simple: bool,
185) {
186 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
187 let function_name = &call_config.function;
188 let result_var = &call_config.result_var;
189
190 let test_name = sanitize_ident(&fixture.id);
191 let description = fixture.description.replace('"', "\\\"");
192
193 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
194
195 let args_str = build_args_string(&fixture.input, &call_config.args);
196
197 let mut setup_lines = Vec::new();
199 let final_args = if let Some(visitor_spec) = &fixture.visitor {
200 build_r_visitor(&mut setup_lines, visitor_spec);
201 if args_str.is_empty() {
202 "visitor".to_string()
203 } else {
204 format!("{args_str}, visitor = visitor")
205 }
206 } else {
207 args_str
208 };
209
210 if expects_error {
211 let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
212 for line in &setup_lines {
213 let _ = writeln!(out, " {line}");
214 }
215 let _ = writeln!(out, " expect_error({function_name}({final_args}))");
216 let _ = writeln!(out, "}})");
217 return;
218 }
219
220 let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
221 for line in &setup_lines {
222 let _ = writeln!(out, " {line}");
223 }
224 let _ = writeln!(out, " {result_var} <- {function_name}({final_args})");
225
226 for assertion in &fixture.assertions {
227 render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
228 }
229
230 let _ = writeln!(out, "}})");
231}
232
233fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
234 if args.is_empty() {
235 return json_to_r(input, true);
236 }
237
238 let parts: Vec<String> = args
239 .iter()
240 .filter_map(|arg| {
241 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
242 let val = input.get(field)?;
243 if val.is_null() && arg.optional {
244 return None;
245 }
246 Some(format!("{} = {}", arg.name, json_to_r(val, true)))
247 })
248 .collect();
249
250 parts.join(", ")
251}
252
253fn render_assertion(
254 out: &mut String,
255 assertion: &Assertion,
256 result_var: &str,
257 field_resolver: &FieldResolver,
258 result_is_simple: bool,
259 _e2e_config: &E2eConfig,
260) {
261 if let Some(f) = &assertion.field {
263 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
264 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
265 return;
266 }
267 }
268
269 if result_is_simple {
272 if let Some(f) = &assertion.field {
273 let f_lower = f.to_lowercase();
274 if !f.is_empty()
275 && f_lower != "content"
276 && (f_lower.starts_with("metadata")
277 || f_lower.starts_with("document")
278 || f_lower.starts_with("structure"))
279 {
280 let _ = writeln!(
281 out,
282 " # skipped: result_is_simple for field '{f}' not available on result type"
283 );
284 return;
285 }
286 }
287 }
288
289 let field_expr = if result_is_simple {
290 result_var.to_string()
291 } else {
292 match &assertion.field {
293 Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
294 _ => result_var.to_string(),
295 }
296 };
297
298 match assertion.assertion_type.as_str() {
299 "equals" => {
300 if let Some(expected) = &assertion.value {
301 let r_val = json_to_r(expected, false);
302 let _ = writeln!(out, " expect_equal(trimws({field_expr}), {r_val})");
303 }
304 }
305 "contains" => {
306 if let Some(expected) = &assertion.value {
307 let r_val = json_to_r(expected, false);
308 let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
309 }
310 }
311 "contains_all" => {
312 if let Some(values) = &assertion.values {
313 for val in values {
314 let r_val = json_to_r(val, false);
315 let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
316 }
317 }
318 }
319 "not_contains" => {
320 if let Some(expected) = &assertion.value {
321 let r_val = json_to_r(expected, false);
322 let _ = writeln!(out, " expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
323 }
324 }
325 "not_empty" => {
326 let _ = writeln!(
327 out,
328 " expect_true(if (is.character({field_expr})) nchar({field_expr}) > 0 else length({field_expr}) > 0)"
329 );
330 }
331 "is_empty" => {
332 let _ = writeln!(out, " expect_equal({field_expr}, \"\")");
333 }
334 "contains_any" => {
335 if let Some(values) = &assertion.values {
336 let items: Vec<String> = values.iter().map(|v| json_to_r(v, false)).collect();
337 let vec_str = items.join(", ");
338 let _ = writeln!(
339 out,
340 " expect_true(any(sapply(c({vec_str}), function(v) grepl(v, {field_expr}, fixed = TRUE))))"
341 );
342 }
343 }
344 "greater_than" => {
345 if let Some(val) = &assertion.value {
346 let r_val = json_to_r(val, false);
347 let _ = writeln!(out, " expect_true({field_expr} > {r_val})");
348 }
349 }
350 "less_than" => {
351 if let Some(val) = &assertion.value {
352 let r_val = json_to_r(val, false);
353 let _ = writeln!(out, " expect_true({field_expr} < {r_val})");
354 }
355 }
356 "greater_than_or_equal" => {
357 if let Some(val) = &assertion.value {
358 let r_val = json_to_r(val, false);
359 let _ = writeln!(out, " expect_true({field_expr} >= {r_val})");
360 }
361 }
362 "less_than_or_equal" => {
363 if let Some(val) = &assertion.value {
364 let r_val = json_to_r(val, false);
365 let _ = writeln!(out, " expect_true({field_expr} <= {r_val})");
366 }
367 }
368 "starts_with" => {
369 if let Some(expected) = &assertion.value {
370 let r_val = json_to_r(expected, false);
371 let _ = writeln!(out, " expect_true(startsWith({field_expr}, {r_val}))");
372 }
373 }
374 "ends_with" => {
375 if let Some(expected) = &assertion.value {
376 let r_val = json_to_r(expected, false);
377 let _ = writeln!(out, " expect_true(endsWith({field_expr}, {r_val}))");
378 }
379 }
380 "min_length" => {
381 if let Some(val) = &assertion.value {
382 if let Some(n) = val.as_u64() {
383 let _ = writeln!(out, " expect_true(nchar({field_expr}) >= {n})");
384 }
385 }
386 }
387 "max_length" => {
388 if let Some(val) = &assertion.value {
389 if let Some(n) = val.as_u64() {
390 let _ = writeln!(out, " expect_true(nchar({field_expr}) <= {n})");
391 }
392 }
393 }
394 "count_min" => {
395 if let Some(val) = &assertion.value {
396 if let Some(n) = val.as_u64() {
397 let _ = writeln!(out, " expect_true(length({field_expr}) >= {n})");
398 }
399 }
400 }
401 "count_equals" => {
402 if let Some(val) = &assertion.value {
403 if let Some(n) = val.as_u64() {
404 let _ = writeln!(out, " expect_equal(length({field_expr}), {n})");
405 }
406 }
407 }
408 "is_true" => {
409 let _ = writeln!(out, " expect_true({field_expr})");
410 }
411 "is_false" => {
412 let _ = writeln!(out, " expect_false({field_expr})");
413 }
414 "method_result" => {
415 if let Some(method_name) = &assertion.method {
416 let call_expr = build_r_method_call(result_var, method_name, assertion.args.as_ref());
417 let check = assertion.check.as_deref().unwrap_or("is_true");
418 match check {
419 "equals" => {
420 if let Some(val) = &assertion.value {
421 if val.is_boolean() {
422 if val.as_bool() == Some(true) {
423 let _ = writeln!(out, " expect_true({call_expr})");
424 } else {
425 let _ = writeln!(out, " expect_false({call_expr})");
426 }
427 } else {
428 let r_val = json_to_r(val, false);
429 let _ = writeln!(out, " expect_equal({call_expr}, {r_val})");
430 }
431 }
432 }
433 "is_true" => {
434 let _ = writeln!(out, " expect_true({call_expr})");
435 }
436 "is_false" => {
437 let _ = writeln!(out, " expect_false({call_expr})");
438 }
439 "greater_than_or_equal" => {
440 if let Some(val) = &assertion.value {
441 let r_val = json_to_r(val, false);
442 let _ = writeln!(out, " expect_true({call_expr} >= {r_val})");
443 }
444 }
445 "count_min" => {
446 if let Some(val) = &assertion.value {
447 let n = val.as_u64().unwrap_or(0);
448 let _ = writeln!(out, " expect_true(length({call_expr}) >= {n})");
449 }
450 }
451 "is_error" => {
452 let _ = writeln!(out, " expect_error({call_expr})");
453 }
454 "contains" => {
455 if let Some(val) = &assertion.value {
456 let r_val = json_to_r(val, false);
457 let _ = writeln!(out, " expect_true(grepl({r_val}, {call_expr}, fixed = TRUE))");
458 }
459 }
460 other_check => {
461 panic!("R e2e generator: unsupported method_result check type: {other_check}");
462 }
463 }
464 } else {
465 panic!("R e2e generator: method_result assertion missing 'method' field");
466 }
467 }
468 "not_error" => {
469 }
471 "error" => {
472 }
474 other => {
475 panic!("R e2e generator: unsupported assertion type: {other}");
476 }
477 }
478}
479
480fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
488 match value {
489 serde_json::Value::String(s) => {
490 let normalized = if lowercase_enum_values && s.chars().next().is_some_and(|c| c.is_uppercase()) {
492 s.to_lowercase()
493 } else {
494 s.clone()
495 };
496 format!("\"{}\"", escape_r(&normalized))
497 }
498 serde_json::Value::Bool(true) => "TRUE".to_string(),
499 serde_json::Value::Bool(false) => "FALSE".to_string(),
500 serde_json::Value::Number(n) => n.to_string(),
501 serde_json::Value::Null => "NULL".to_string(),
502 serde_json::Value::Array(arr) => {
503 let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
504 format!("c({})", items.join(", "))
505 }
506 serde_json::Value::Object(map) => {
507 let entries: Vec<String> = map
508 .iter()
509 .map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v, lowercase_enum_values)))
510 .collect();
511 format!("list({})", entries.join(", "))
512 }
513 }
514}
515
516fn build_r_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
518 use std::fmt::Write as FmtWrite;
519 let mut visitor_obj = String::new();
520 let _ = writeln!(visitor_obj, "list(");
521 for (method_name, action) in &visitor_spec.callbacks {
522 emit_r_visitor_method(&mut visitor_obj, method_name, action);
523 }
524 let _ = writeln!(visitor_obj, " )");
525
526 setup_lines.push(format!("visitor <- {visitor_obj}"));
527}
528
529fn build_r_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
532 match method_name {
533 "root_child_count" => format!("{result_var}$root_child_count()"),
534 "root_node_type" => format!("{result_var}$root_node_type()"),
535 "named_children_count" => format!("{result_var}$named_children_count()"),
536 "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
537 "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
538 "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
539 "contains_node_type" => {
540 let node_type = args
541 .and_then(|a| a.get("node_type"))
542 .and_then(|v| v.as_str())
543 .unwrap_or("");
544 format!("tree_contains_node_type({result_var}, \"{node_type}\")")
545 }
546 "find_nodes_by_type" => {
547 let node_type = args
548 .and_then(|a| a.get("node_type"))
549 .and_then(|v| v.as_str())
550 .unwrap_or("");
551 format!("find_nodes_by_type({result_var}, \"{node_type}\")")
552 }
553 "run_query" => {
554 let query_source = args
555 .and_then(|a| a.get("query_source"))
556 .and_then(|v| v.as_str())
557 .unwrap_or("");
558 let language = args
559 .and_then(|a| a.get("language"))
560 .and_then(|v| v.as_str())
561 .unwrap_or("");
562 format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
563 }
564 _ => {
565 if let Some(args_val) = args {
566 let arg_str = args_val
567 .as_object()
568 .map(|obj| {
569 obj.iter()
570 .map(|(k, v)| {
571 let r_val = json_to_r(v, false);
572 format!("{k} = {r_val}")
573 })
574 .collect::<Vec<_>>()
575 .join(", ")
576 })
577 .unwrap_or_default();
578 format!("{result_var}${method_name}({arg_str})")
579 } else {
580 format!("{result_var}${method_name}()")
581 }
582 }
583 }
584}
585
586fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
588 use std::fmt::Write as FmtWrite;
589
590 let params = match method_name {
592 "visit_link" => "ctx, href, text, title",
593 "visit_image" => "ctx, src, alt, title",
594 "visit_heading" => "ctx, level, text, id",
595 "visit_code_block" => "ctx, lang, code",
596 "visit_code_inline"
597 | "visit_strong"
598 | "visit_emphasis"
599 | "visit_strikethrough"
600 | "visit_underline"
601 | "visit_subscript"
602 | "visit_superscript"
603 | "visit_mark"
604 | "visit_button"
605 | "visit_summary"
606 | "visit_figcaption"
607 | "visit_definition_term"
608 | "visit_definition_description" => "ctx, text",
609 "visit_text" => "ctx, text",
610 "visit_list_item" => "ctx, ordered, marker, text",
611 "visit_blockquote" => "ctx, content, depth",
612 "visit_table_row" => "ctx, cells, is_header",
613 "visit_custom_element" => "ctx, tag_name, html",
614 "visit_form" => "ctx, action_url, method",
615 "visit_input" => "ctx, input_type, name, value",
616 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
617 "visit_details" => "ctx, is_open",
618 _ => "ctx",
619 };
620
621 let _ = writeln!(out, " {method_name} = function({params}) {{");
622 match action {
623 CallbackAction::Skip => {
624 let _ = writeln!(out, " \"skip\"");
625 }
626 CallbackAction::Continue => {
627 let _ = writeln!(out, " \"continue\"");
628 }
629 CallbackAction::PreserveHtml => {
630 let _ = writeln!(out, " \"preserve_html\"");
631 }
632 CallbackAction::Custom { output } => {
633 let escaped = escape_r(output);
634 let _ = writeln!(out, " list(custom = {escaped})");
635 }
636 CallbackAction::CustomTemplate { template } => {
637 let escaped = escape_r(template);
638 let _ = writeln!(out, " list(custom = {escaped})");
639 }
640 }
641 let _ = writeln!(out, " }},");
642}