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