use crate::config::E2eConfig;
use crate::escape::{escape_r, sanitize_filename, sanitize_ident};
use crate::field_access::FieldResolver;
use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
use alef_core::backend::GeneratedFile;
use alef_core::config::AlefConfig;
use alef_core::hash::{self, CommentStyle};
use anyhow::Result;
use std::fmt::Write as FmtWrite;
use std::path::PathBuf;
use super::E2eCodegen;
pub struct RCodegen;
impl E2eCodegen for RCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
_alef_config: &AlefConfig,
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let mut files = Vec::new();
let call = &e2e_config.call;
let overrides = call.overrides.get(lang);
let module_path = overrides
.and_then(|o| o.module.as_ref())
.cloned()
.unwrap_or_else(|| call.module.clone());
let _function_name = overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call.function.clone());
let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
let _result_var = &call.result_var;
let r_pkg = e2e_config.resolve_package("r");
let pkg_name = r_pkg
.as_ref()
.and_then(|p| p.name.as_ref())
.cloned()
.unwrap_or_else(|| module_path.clone());
let pkg_path = r_pkg
.as_ref()
.and_then(|p| p.path.as_ref())
.cloned()
.unwrap_or_else(|| "../../packages/r".to_string());
let pkg_version = r_pkg
.as_ref()
.and_then(|p| p.version.as_ref())
.cloned()
.unwrap_or_else(|| "0.1.0".to_string());
files.push(GeneratedFile {
path: output_base.join("DESCRIPTION"),
content: render_description(&pkg_name, &pkg_version, e2e_config.dep_mode),
generated_header: false,
});
files.push(GeneratedFile {
path: output_base.join("run_tests.R"),
content: render_test_runner(&pkg_path, e2e_config.dep_mode),
generated_header: true,
});
for group in groups {
let active: Vec<&Fixture> = group
.fixtures
.iter()
.filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
.collect();
if active.is_empty() {
continue;
}
let filename = format!("test_{}.R", sanitize_filename(&group.category));
let field_resolver = FieldResolver::new(
&e2e_config.fields,
&e2e_config.fields_optional,
&e2e_config.result_fields,
&e2e_config.fields_array,
);
let content = render_test_file(&group.category, &active, &field_resolver, result_is_simple, e2e_config);
files.push(GeneratedFile {
path: output_base.join("tests").join(filename),
content,
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"r"
}
}
fn render_description(pkg_name: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
let dep_line = match dep_mode {
crate::config::DependencyMode::Registry => {
format!("Imports: {pkg_name} ({pkg_version})\n")
}
crate::config::DependencyMode::Local => String::new(),
};
format!(
r#"Package: e2e.r
Title: E2E Tests for {pkg_name}
Version: 0.1.0
Description: End-to-end test suite.
{dep_line}Suggests: testthat (>= 3.0.0)
Config/testthat/edition: 3
"#
)
}
fn render_test_runner(pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::Hash));
let _ = writeln!(out, "library(testthat)");
match dep_mode {
crate::config::DependencyMode::Registry => {
let _ = writeln!(out, "# Package loaded via library() from CRAN install.");
}
crate::config::DependencyMode::Local => {
let _ = writeln!(out, "devtools::load_all(\"{pkg_path}\")");
}
}
let _ = writeln!(out);
let _ = writeln!(out, "test_dir(\"tests\")");
out
}
fn render_test_file(
category: &str,
fixtures: &[&Fixture],
field_resolver: &FieldResolver,
result_is_simple: bool,
e2e_config: &E2eConfig,
) -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::Hash));
let _ = writeln!(out, "# E2e tests for category: {category}");
let _ = writeln!(out);
for (i, fixture) in fixtures.iter().enumerate() {
render_test_case(&mut out, fixture, e2e_config, field_resolver, result_is_simple);
if i + 1 < fixtures.len() {
let _ = writeln!(out);
}
}
while out.ends_with("\n\n") {
out.pop();
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn render_test_case(
out: &mut String,
fixture: &Fixture,
e2e_config: &E2eConfig,
field_resolver: &FieldResolver,
result_is_simple: bool,
) {
let call_config = e2e_config.resolve_call(fixture.call.as_deref());
let function_name = &call_config.function;
let result_var = &call_config.result_var;
let test_name = sanitize_ident(&fixture.id);
let description = fixture.description.replace('"', "\\\"");
let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
let args_str = build_args_string(&fixture.input, &call_config.args);
let mut setup_lines = Vec::new();
let final_args = if let Some(visitor_spec) = &fixture.visitor {
build_r_visitor(&mut setup_lines, visitor_spec);
if args_str.is_empty() {
"visitor".to_string()
} else {
format!("{args_str}, visitor = visitor")
}
} else {
args_str
};
if expects_error {
let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
for line in &setup_lines {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " expect_error({function_name}({final_args}))");
let _ = writeln!(out, "}})");
return;
}
let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
for line in &setup_lines {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " {result_var} <- {function_name}({final_args})");
for assertion in &fixture.assertions {
render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
}
let _ = writeln!(out, "}})");
}
fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
if args.is_empty() {
return json_to_r(input, true);
}
let parts: Vec<String> = args
.iter()
.filter_map(|arg| {
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let val = input.get(field)?;
if val.is_null() && arg.optional {
return None;
}
Some(format!("{} = {}", arg.name, json_to_r(val, true)))
})
.collect();
parts.join(", ")
}
fn render_assertion(
out: &mut String,
assertion: &Assertion,
result_var: &str,
field_resolver: &FieldResolver,
result_is_simple: bool,
_e2e_config: &E2eConfig,
) {
if let Some(f) = &assertion.field {
if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
return;
}
}
if result_is_simple {
if let Some(f) = &assertion.field {
let f_lower = f.to_lowercase();
if !f.is_empty()
&& f_lower != "content"
&& (f_lower.starts_with("metadata")
|| f_lower.starts_with("document")
|| f_lower.starts_with("structure"))
{
let _ = writeln!(
out,
" # skipped: result_is_simple for field '{f}' not available on result type"
);
return;
}
}
}
let field_expr = if result_is_simple {
result_var.to_string()
} else {
match &assertion.field {
Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
_ => result_var.to_string(),
}
};
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(expected) = &assertion.value {
let r_val = json_to_r(expected, false);
let _ = writeln!(out, " expect_equal(trimws({field_expr}), {r_val})");
}
}
"contains" => {
if let Some(expected) = &assertion.value {
let r_val = json_to_r(expected, false);
let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
}
}
"contains_all" => {
if let Some(values) = &assertion.values {
for val in values {
let r_val = json_to_r(val, false);
let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
}
}
}
"not_contains" => {
if let Some(expected) = &assertion.value {
let r_val = json_to_r(expected, false);
let _ = writeln!(out, " expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
}
}
"not_empty" => {
let _ = writeln!(
out,
" expect_true(if (is.character({field_expr})) nchar({field_expr}) > 0 else length({field_expr}) > 0)"
);
}
"is_empty" => {
let _ = writeln!(out, " expect_equal({field_expr}, \"\")");
}
"contains_any" => {
if let Some(values) = &assertion.values {
let items: Vec<String> = values.iter().map(|v| json_to_r(v, false)).collect();
let vec_str = items.join(", ");
let _ = writeln!(
out,
" expect_true(any(sapply(c({vec_str}), function(v) grepl(v, {field_expr}, fixed = TRUE))))"
);
}
}
"greater_than" => {
if let Some(val) = &assertion.value {
let r_val = json_to_r(val, false);
let _ = writeln!(out, " expect_true({field_expr} > {r_val})");
}
}
"less_than" => {
if let Some(val) = &assertion.value {
let r_val = json_to_r(val, false);
let _ = writeln!(out, " expect_true({field_expr} < {r_val})");
}
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let r_val = json_to_r(val, false);
let _ = writeln!(out, " expect_true({field_expr} >= {r_val})");
}
}
"less_than_or_equal" => {
if let Some(val) = &assertion.value {
let r_val = json_to_r(val, false);
let _ = writeln!(out, " expect_true({field_expr} <= {r_val})");
}
}
"starts_with" => {
if let Some(expected) = &assertion.value {
let r_val = json_to_r(expected, false);
let _ = writeln!(out, " expect_true(startsWith({field_expr}, {r_val}))");
}
}
"ends_with" => {
if let Some(expected) = &assertion.value {
let r_val = json_to_r(expected, false);
let _ = writeln!(out, " expect_true(endsWith({field_expr}, {r_val}))");
}
}
"min_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(out, " expect_true(nchar({field_expr}) >= {n})");
}
}
}
"max_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(out, " expect_true(nchar({field_expr}) <= {n})");
}
}
}
"count_min" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(out, " expect_true(length({field_expr}) >= {n})");
}
}
}
"count_equals" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(out, " expect_equal(length({field_expr}), {n})");
}
}
}
"is_true" => {
let _ = writeln!(out, " expect_true({field_expr})");
}
"is_false" => {
let _ = writeln!(out, " expect_false({field_expr})");
}
"method_result" => {
if let Some(method_name) = &assertion.method {
let call_expr = build_r_method_call(result_var, method_name, assertion.args.as_ref());
let check = assertion.check.as_deref().unwrap_or("is_true");
match check {
"equals" => {
if let Some(val) = &assertion.value {
if val.is_boolean() {
if val.as_bool() == Some(true) {
let _ = writeln!(out, " expect_true({call_expr})");
} else {
let _ = writeln!(out, " expect_false({call_expr})");
}
} else {
let r_val = json_to_r(val, false);
let _ = writeln!(out, " expect_equal({call_expr}, {r_val})");
}
}
}
"is_true" => {
let _ = writeln!(out, " expect_true({call_expr})");
}
"is_false" => {
let _ = writeln!(out, " expect_false({call_expr})");
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let r_val = json_to_r(val, false);
let _ = writeln!(out, " expect_true({call_expr} >= {r_val})");
}
}
"count_min" => {
if let Some(val) = &assertion.value {
let n = val.as_u64().unwrap_or(0);
let _ = writeln!(out, " expect_true(length({call_expr}) >= {n})");
}
}
"is_error" => {
let _ = writeln!(out, " expect_error({call_expr})");
}
"contains" => {
if let Some(val) = &assertion.value {
let r_val = json_to_r(val, false);
let _ = writeln!(out, " expect_true(grepl({r_val}, {call_expr}, fixed = TRUE))");
}
}
other_check => {
panic!("R e2e generator: unsupported method_result check type: {other_check}");
}
}
} else {
panic!("R e2e generator: method_result assertion missing 'method' field");
}
}
"matches_regex" => {
if let Some(expected) = &assertion.value {
let r_val = json_to_r(expected, false);
let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}))");
}
}
"not_error" => {
}
"error" => {
}
other => {
panic!("R e2e generator: unsupported assertion type: {other}");
}
}
}
fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
match value {
serde_json::Value::String(s) => {
let normalized = if lowercase_enum_values && s.chars().next().is_some_and(|c| c.is_uppercase()) {
s.to_lowercase()
} else {
s.clone()
};
format!("\"{}\"", escape_r(&normalized))
}
serde_json::Value::Bool(true) => "TRUE".to_string(),
serde_json::Value::Bool(false) => "FALSE".to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => "NULL".to_string(),
serde_json::Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
format!("c({})", items.join(", "))
}
serde_json::Value::Object(map) => {
let entries: Vec<String> = map
.iter()
.map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v, lowercase_enum_values)))
.collect();
format!("list({})", entries.join(", "))
}
}
}
fn build_r_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
use std::fmt::Write as FmtWrite;
let mut visitor_obj = String::new();
let _ = writeln!(visitor_obj, "list(");
for (method_name, action) in &visitor_spec.callbacks {
emit_r_visitor_method(&mut visitor_obj, method_name, action);
}
let _ = writeln!(visitor_obj, " )");
setup_lines.push(format!("visitor <- {visitor_obj}"));
}
fn build_r_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
match method_name {
"root_child_count" => format!("{result_var}$root_child_count()"),
"root_node_type" => format!("{result_var}$root_node_type()"),
"named_children_count" => format!("{result_var}$named_children_count()"),
"has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
"error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
"tree_to_sexp" => format!("tree_to_sexp({result_var})"),
"contains_node_type" => {
let node_type = args
.and_then(|a| a.get("node_type"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("tree_contains_node_type({result_var}, \"{node_type}\")")
}
"find_nodes_by_type" => {
let node_type = args
.and_then(|a| a.get("node_type"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("find_nodes_by_type({result_var}, \"{node_type}\")")
}
"run_query" => {
let query_source = args
.and_then(|a| a.get("query_source"))
.and_then(|v| v.as_str())
.unwrap_or("");
let language = args
.and_then(|a| a.get("language"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
}
_ => {
if let Some(args_val) = args {
let arg_str = args_val
.as_object()
.map(|obj| {
obj.iter()
.map(|(k, v)| {
let r_val = json_to_r(v, false);
format!("{k} = {r_val}")
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
format!("{result_var}${method_name}({arg_str})")
} else {
format!("{result_var}${method_name}()")
}
}
}
}
fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
use std::fmt::Write as FmtWrite;
let params = match method_name {
"visit_link" => "ctx, href, text, title",
"visit_image" => "ctx, src, alt, title",
"visit_heading" => "ctx, level, text, id",
"visit_code_block" => "ctx, lang, code",
"visit_code_inline"
| "visit_strong"
| "visit_emphasis"
| "visit_strikethrough"
| "visit_underline"
| "visit_subscript"
| "visit_superscript"
| "visit_mark"
| "visit_button"
| "visit_summary"
| "visit_figcaption"
| "visit_definition_term"
| "visit_definition_description" => "ctx, text",
"visit_text" => "ctx, text",
"visit_list_item" => "ctx, ordered, marker, text",
"visit_blockquote" => "ctx, content, depth",
"visit_table_row" => "ctx, cells, is_header",
"visit_custom_element" => "ctx, tag_name, html",
"visit_form" => "ctx, action_url, method",
"visit_input" => "ctx, input_type, name, value",
"visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
"visit_details" => "ctx, is_open",
"visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
"visit_list_start" => "ctx, ordered",
"visit_list_end" => "ctx, ordered, output",
_ => "ctx",
};
let _ = writeln!(out, " {method_name} = function({params}) {{");
match action {
CallbackAction::Skip => {
let _ = writeln!(out, " \"skip\"");
}
CallbackAction::Continue => {
let _ = writeln!(out, " \"continue\"");
}
CallbackAction::PreserveHtml => {
let _ = writeln!(out, " \"preserve_html\"");
}
CallbackAction::Custom { output } => {
let escaped = escape_r(output);
let _ = writeln!(out, " list(custom = {escaped})");
}
CallbackAction::CustomTemplate { template } => {
let escaped = escape_r(template);
let _ = writeln!(out, " list(custom = {escaped})");
}
}
let _ = writeln!(out, " }},");
}