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 anyhow::Result;
10use std::fmt::Write as FmtWrite;
11use std::path::PathBuf;
12
13use super::E2eCodegen;
14
15/// R e2e code generator.
16pub 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        // Resolve call config with overrides.
31        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        // Resolve package config.
45        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        // Generate DESCRIPTION file.
63        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        // Generate test runner script.
70        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        // Generate test files per category.
77        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            // In registry mode, require the installed CRAN package directly.
136            let _ = writeln!(out, "# Package loaded via library() from CRAN install.");
137        }
138        crate::config::DependencyMode::Local => {
139            // Use devtools::load_all() to load the local R package without requiring
140            // a full install, matching the e2e test runner convention.
141            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    // Clean up trailing newlines.
169    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    // Build visitor setup and args if present
197    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    // Skip assertions on fields that don't exist on the result type.
261    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    // When result_is_simple, skip assertions that reference non-content fields
269    // (e.g., metadata, document, structure) since the binding returns a plain value.
270    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            // Already handled — the call would stop on error.
469        }
470        "error" => {
471            // Handled at the test level.
472        }
473        other => {
474            panic!("R e2e generator: unsupported assertion type: {other}");
475        }
476    }
477}
478
479/// Convert a `serde_json::Value` to an R literal string.
480///
481/// # Arguments
482///
483/// * `value` - The JSON value to convert
484/// * `lowercase_enum_values` - If true, lowercase strings starting with uppercase letter (for enum values).
485///   If false, preserve original case (for assertion expected values).
486fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
487    match value {
488        serde_json::Value::String(s) => {
489            // Lowercase enum values (strings starting with uppercase letter) only if requested
490            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
515/// Build an R visitor list and add setup line.
516fn 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
528/// Build an R call expression for a `method_result` assertion.
529/// Maps method names to the appropriate R function or method calls.
530fn 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
585/// Emit an R visitor method for a callback action.
586fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
587    use std::fmt::Write as FmtWrite;
588
589    // R uses visit_ prefix (matches binding signature)
590    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}