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(
96 &group.category,
97 &active,
98 &function_name,
99 result_var,
100 &e2e_config.call.args,
101 &field_resolver,
102 result_is_simple,
103 );
104 files.push(GeneratedFile {
105 path: output_base.join("tests").join(filename),
106 content,
107 generated_header: true,
108 });
109 }
110
111 Ok(files)
112 }
113
114 fn language_name(&self) -> &'static str {
115 "r"
116 }
117}
118
119fn render_description(pkg_name: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
120 let dep_line = match dep_mode {
121 crate::config::DependencyMode::Registry => {
122 format!("Imports: {pkg_name} ({pkg_version})\n")
123 }
124 crate::config::DependencyMode::Local => String::new(),
125 };
126 format!(
127 r#"Package: e2e.r
128Title: E2E Tests for {pkg_name}
129Version: 0.1.0
130Description: End-to-end test suite.
131{dep_line}Suggests: testthat (>= 3.0.0)
132Config/testthat/edition: 3
133"#
134 )
135}
136
137fn render_test_runner(pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
138 let mut out = String::new();
139 let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
140 let _ = writeln!(out, "library(testthat)");
141 match dep_mode {
142 crate::config::DependencyMode::Registry => {
143 let _ = writeln!(out, "# Package loaded via library() from CRAN install.");
145 }
146 crate::config::DependencyMode::Local => {
147 let _ = writeln!(out, "devtools::load_all(\"{pkg_path}\")");
150 }
151 }
152 let _ = writeln!(out);
153 let _ = writeln!(out, "test_dir(\"tests\")");
154 out
155}
156
157fn render_test_file(
158 category: &str,
159 fixtures: &[&Fixture],
160 function_name: &str,
161 result_var: &str,
162 args: &[crate::config::ArgMapping],
163 field_resolver: &FieldResolver,
164 result_is_simple: bool,
165) -> String {
166 let mut out = String::new();
167 let _ = writeln!(out, "# This file is auto-generated by alef. DO NOT EDIT.");
168 let _ = writeln!(out, "# E2e tests for category: {category}");
169 let _ = writeln!(out);
170
171 for (i, fixture) in fixtures.iter().enumerate() {
172 render_test_case(
173 &mut out,
174 fixture,
175 function_name,
176 result_var,
177 args,
178 field_resolver,
179 result_is_simple,
180 );
181 if i + 1 < fixtures.len() {
182 let _ = writeln!(out);
183 }
184 }
185
186 while out.ends_with("\n\n") {
188 out.pop();
189 }
190 if !out.ends_with('\n') {
191 out.push('\n');
192 }
193 out
194}
195
196fn render_test_case(
197 out: &mut String,
198 fixture: &Fixture,
199 function_name: &str,
200 result_var: &str,
201 args: &[crate::config::ArgMapping],
202 field_resolver: &FieldResolver,
203 result_is_simple: bool,
204) {
205 let test_name = sanitize_ident(&fixture.id);
206 let description = fixture.description.replace('"', "\\\"");
207
208 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
209
210 let args_str = build_args_string(&fixture.input, args);
211
212 let mut setup_lines = Vec::new();
214 let final_args = if let Some(visitor_spec) = &fixture.visitor {
215 build_r_visitor(&mut setup_lines, visitor_spec);
216 if args_str.is_empty() {
217 "visitor".to_string()
218 } else {
219 format!("{args_str}, visitor = visitor")
220 }
221 } else {
222 args_str
223 };
224
225 if expects_error {
226 let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
227 for line in &setup_lines {
228 let _ = writeln!(out, " {line}");
229 }
230 let _ = writeln!(out, " expect_error({function_name}({final_args}))");
231 let _ = writeln!(out, "}})");
232 return;
233 }
234
235 let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
236 for line in &setup_lines {
237 let _ = writeln!(out, " {line}");
238 }
239 let _ = writeln!(out, " {result_var} <- {function_name}({final_args})");
240
241 for assertion in &fixture.assertions {
242 render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
243 }
244
245 let _ = writeln!(out, "}})");
246}
247
248fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
249 if args.is_empty() {
250 return json_to_r(input, true);
251 }
252
253 let parts: Vec<String> = args
254 .iter()
255 .filter_map(|arg| {
256 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
257 let val = input.get(field)?;
258 if val.is_null() && arg.optional {
259 return None;
260 }
261 Some(format!("{} = {}", arg.name, json_to_r(val, true)))
262 })
263 .collect();
264
265 parts.join(", ")
266}
267
268fn render_assertion(
269 out: &mut String,
270 assertion: &Assertion,
271 result_var: &str,
272 field_resolver: &FieldResolver,
273 result_is_simple: bool,
274) {
275 if let Some(f) = &assertion.field {
277 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
278 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
279 return;
280 }
281 }
282
283 if result_is_simple {
286 if let Some(f) = &assertion.field {
287 let f_lower = f.to_lowercase();
288 if !f.is_empty()
289 && f_lower != "content"
290 && (f_lower.starts_with("metadata")
291 || f_lower.starts_with("document")
292 || f_lower.starts_with("structure"))
293 {
294 let _ = writeln!(out, " # TODO: skipped (result_is_simple, field: {f})");
295 return;
296 }
297 }
298 }
299
300 let field_expr = if result_is_simple {
301 result_var.to_string()
302 } else {
303 match &assertion.field {
304 Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
305 _ => result_var.to_string(),
306 }
307 };
308
309 match assertion.assertion_type.as_str() {
310 "equals" => {
311 if let Some(expected) = &assertion.value {
312 let r_val = json_to_r(expected, false);
313 let _ = writeln!(out, " expect_equal(trimws({field_expr}), {r_val})");
314 }
315 }
316 "contains" => {
317 if let Some(expected) = &assertion.value {
318 let r_val = json_to_r(expected, false);
319 let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
320 }
321 }
322 "contains_all" => {
323 if let Some(values) = &assertion.values {
324 for val in values {
325 let r_val = json_to_r(val, false);
326 let _ = writeln!(out, " expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
327 }
328 }
329 }
330 "not_contains" => {
331 if let Some(expected) = &assertion.value {
332 let r_val = json_to_r(expected, false);
333 let _ = writeln!(out, " expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
334 }
335 }
336 "not_empty" => {
337 let _ = writeln!(
338 out,
339 " expect_true(if (is.character({field_expr})) nchar({field_expr}) > 0 else length({field_expr}) > 0)"
340 );
341 }
342 "is_empty" => {
343 let _ = writeln!(out, " expect_equal({field_expr}, \"\")");
344 }
345 "contains_any" => {
346 if let Some(values) = &assertion.values {
347 let items: Vec<String> = values.iter().map(|v| json_to_r(v, false)).collect();
348 let vec_str = items.join(", ");
349 let _ = writeln!(
350 out,
351 " expect_true(any(sapply(c({vec_str}), function(v) grepl(v, {field_expr}, fixed = TRUE))))"
352 );
353 }
354 }
355 "greater_than" => {
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" => {
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 "greater_than_or_equal" => {
368 if let Some(val) = &assertion.value {
369 let r_val = json_to_r(val, false);
370 let _ = writeln!(out, " expect_true({field_expr} >= {r_val})");
371 }
372 }
373 "less_than_or_equal" => {
374 if let Some(val) = &assertion.value {
375 let r_val = json_to_r(val, false);
376 let _ = writeln!(out, " expect_true({field_expr} <= {r_val})");
377 }
378 }
379 "starts_with" => {
380 if let Some(expected) = &assertion.value {
381 let r_val = json_to_r(expected, false);
382 let _ = writeln!(out, " expect_true(startsWith({field_expr}, {r_val}))");
383 }
384 }
385 "ends_with" => {
386 if let Some(expected) = &assertion.value {
387 let r_val = json_to_r(expected, false);
388 let _ = writeln!(out, " expect_true(endsWith({field_expr}, {r_val}))");
389 }
390 }
391 "min_length" => {
392 if let Some(val) = &assertion.value {
393 if let Some(n) = val.as_u64() {
394 let _ = writeln!(out, " expect_true(nchar({field_expr}) >= {n})");
395 }
396 }
397 }
398 "max_length" => {
399 if let Some(val) = &assertion.value {
400 if let Some(n) = val.as_u64() {
401 let _ = writeln!(out, " expect_true(nchar({field_expr}) <= {n})");
402 }
403 }
404 }
405 "count_min" => {
406 if let Some(val) = &assertion.value {
407 if let Some(n) = val.as_u64() {
408 let _ = writeln!(out, " expect_true(length({field_expr}) >= {n})");
409 }
410 }
411 }
412 "count_equals" => {
413 if let Some(val) = &assertion.value {
414 if let Some(n) = val.as_u64() {
415 let _ = writeln!(out, " expect_equal(length({field_expr}), {n})");
416 }
417 }
418 }
419 "is_true" => {
420 let _ = writeln!(out, " expect_true({field_expr})");
421 }
422 "not_error" => {
423 }
425 "error" => {
426 }
428 other => {
429 let _ = writeln!(out, " # TODO: unsupported assertion type: {other}");
430 }
431 }
432}
433
434fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
442 match value {
443 serde_json::Value::String(s) => {
444 let normalized = if lowercase_enum_values && s.chars().next().is_some_and(|c| c.is_uppercase()) {
446 s.to_lowercase()
447 } else {
448 s.clone()
449 };
450 format!("\"{}\"", escape_r(&normalized))
451 }
452 serde_json::Value::Bool(true) => "TRUE".to_string(),
453 serde_json::Value::Bool(false) => "FALSE".to_string(),
454 serde_json::Value::Number(n) => n.to_string(),
455 serde_json::Value::Null => "NULL".to_string(),
456 serde_json::Value::Array(arr) => {
457 let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
458 format!("c({})", items.join(", "))
459 }
460 serde_json::Value::Object(map) => {
461 let entries: Vec<String> = map
462 .iter()
463 .map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v, lowercase_enum_values)))
464 .collect();
465 format!("list({})", entries.join(", "))
466 }
467 }
468}
469
470fn build_r_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
472 use std::fmt::Write as FmtWrite;
473 let mut visitor_obj = String::new();
474 let _ = writeln!(visitor_obj, "list(");
475 for (method_name, action) in &visitor_spec.callbacks {
476 emit_r_visitor_method(&mut visitor_obj, method_name, action);
477 }
478 let _ = writeln!(visitor_obj, " )");
479
480 setup_lines.push(format!("visitor <- {visitor_obj}"));
481}
482
483fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
485 use std::fmt::Write as FmtWrite;
486
487 let params = match method_name {
489 "visit_link" => "ctx, href, text, title",
490 "visit_image" => "ctx, src, alt, title",
491 "visit_heading" => "ctx, level, text, id",
492 "visit_code_block" => "ctx, lang, code",
493 "visit_code_inline"
494 | "visit_strong"
495 | "visit_emphasis"
496 | "visit_strikethrough"
497 | "visit_underline"
498 | "visit_subscript"
499 | "visit_superscript"
500 | "visit_mark"
501 | "visit_button"
502 | "visit_summary"
503 | "visit_figcaption"
504 | "visit_definition_term"
505 | "visit_definition_description" => "ctx, text",
506 "visit_text" => "ctx, text",
507 "visit_list_item" => "ctx, ordered, marker, text",
508 "visit_blockquote" => "ctx, content, depth",
509 "visit_table_row" => "ctx, cells, is_header",
510 "visit_custom_element" => "ctx, tag_name, html",
511 "visit_form" => "ctx, action_url, method",
512 "visit_input" => "ctx, input_type, name, value",
513 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
514 "visit_details" => "ctx, is_open",
515 _ => "ctx",
516 };
517
518 let _ = writeln!(out, " {method_name} = function({params}) {{");
519 match action {
520 CallbackAction::Skip => {
521 let _ = writeln!(out, " \"skip\"");
522 }
523 CallbackAction::Continue => {
524 let _ = writeln!(out, " \"continue\"");
525 }
526 CallbackAction::PreserveHtml => {
527 let _ = writeln!(out, " \"preserve_html\"");
528 }
529 CallbackAction::Custom { output } => {
530 let escaped = escape_r(output);
531 let _ = writeln!(out, " list(custom = {escaped})");
532 }
533 CallbackAction::CustomTemplate { template } => {
534 let escaped = escape_r(template);
535 let _ = writeln!(out, " list(custom = {escaped})");
536 }
537 }
538 let _ = writeln!(out, " }},");
539}