Skip to main content

alef_e2e/codegen/
r.rs

1//! R e2e test generator using testthat.
2
3use 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
16/// R e2e code generator.
17pub 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        // Resolve call config with overrides.
32        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        // Resolve package config.
46        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        // Generate DESCRIPTION file.
64        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        // Generate test runner script.
71        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        // Generate test files per category.
78        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            // In registry mode, require the installed CRAN package directly.
137            let _ = writeln!(out, "# Package loaded via library() from CRAN install.");
138        }
139        crate::config::DependencyMode::Local => {
140            // Use devtools::load_all() to load the local R package without requiring
141            // a full install, matching the e2e test runner convention.
142            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    // Clean up trailing newlines.
170    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    // Build visitor setup and args if present
198    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    // Skip assertions on fields that don't exist on the result type.
262    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    // When result_is_simple, skip assertions that reference non-content fields
270    // (e.g., metadata, document, structure) since the binding returns a plain value.
271    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            // Already handled — the call would stop on error.
470        }
471        "error" => {
472            // Handled at the test level.
473        }
474        other => {
475            panic!("R e2e generator: unsupported assertion type: {other}");
476        }
477    }
478}
479
480/// Convert a `serde_json::Value` to an R literal string.
481///
482/// # Arguments
483///
484/// * `value` - The JSON value to convert
485/// * `lowercase_enum_values` - If true, lowercase strings starting with uppercase letter (for enum values).
486///   If false, preserve original case (for assertion expected values).
487fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
488    match value {
489        serde_json::Value::String(s) => {
490            // Lowercase enum values (strings starting with uppercase letter) only if requested
491            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
516/// Build an R visitor list and add setup line.
517fn 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
529/// Build an R call expression for a `method_result` assertion.
530/// Maps method names to the appropriate R function or method calls.
531fn 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
586/// Emit an R visitor method for a callback action.
587fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
588    use std::fmt::Write as FmtWrite;
589
590    // R uses visit_ prefix (matches binding signature)
591    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        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
619        "visit_list_start" => "ctx, ordered",
620        "visit_list_end" => "ctx, ordered, output",
621        _ => "ctx",
622    };
623
624    let _ = writeln!(out, "    {method_name} = function({params}) {{");
625    match action {
626        CallbackAction::Skip => {
627            let _ = writeln!(out, "      \"skip\"");
628        }
629        CallbackAction::Continue => {
630            let _ = writeln!(out, "      \"continue\"");
631        }
632        CallbackAction::PreserveHtml => {
633            let _ = writeln!(out, "      \"preserve_html\"");
634        }
635        CallbackAction::Custom { output } => {
636            let escaped = escape_r(output);
637            let _ = writeln!(out, "      list(custom = {escaped})");
638        }
639        CallbackAction::CustomTemplate { template } => {
640            let escaped = escape_r(template);
641            let _ = writeln!(out, "      list(custom = {escaped})");
642        }
643    }
644    let _ = writeln!(out, "    }},");
645}