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 = call.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    // Handle synthetic / derived fields before the is_valid_for_result check
262    // so they are never treated as struct attribute accesses on the result.
263    if let Some(f) = &assertion.field {
264        match f.as_str() {
265            "chunks_have_content" => {
266                let pred = format!("all(sapply({result_var}$chunks %||% list(), function(c) nchar(c$content) > 0))");
267                match assertion.assertion_type.as_str() {
268                    "is_true" => {
269                        let _ = writeln!(out, "  expect_true({pred})");
270                    }
271                    "is_false" => {
272                        let _ = writeln!(out, "  expect_false({pred})");
273                    }
274                    _ => {
275                        let _ = writeln!(out, "  # skipped: unsupported assertion type on synthetic field '{f}'");
276                    }
277                }
278                return;
279            }
280            "chunks_have_embeddings" => {
281                let pred = format!(
282                    "all(sapply({result_var}$chunks %||% list(), function(c) !is.null(c$embedding) && length(c$embedding) > 0))"
283                );
284                match assertion.assertion_type.as_str() {
285                    "is_true" => {
286                        let _ = writeln!(out, "  expect_true({pred})");
287                    }
288                    "is_false" => {
289                        let _ = writeln!(out, "  expect_false({pred})");
290                    }
291                    _ => {
292                        let _ = writeln!(out, "  # skipped: unsupported assertion type on synthetic field '{f}'");
293                    }
294                }
295                return;
296            }
297            // ---- EmbedResponse virtual fields ----
298            // embed_texts returns list of numeric vectors in R — no wrapper object.
299            // result_var is the embedding matrix; use it directly.
300            "embeddings" => {
301                match assertion.assertion_type.as_str() {
302                    "count_equals" => {
303                        if let Some(val) = &assertion.value {
304                            let r_val = json_to_r(val, false);
305                            let _ = writeln!(out, "  expect_equal(length({result_var}), {r_val})");
306                        }
307                    }
308                    "count_min" => {
309                        if let Some(val) = &assertion.value {
310                            let r_val = json_to_r(val, false);
311                            let _ = writeln!(out, "  expect_gte(length({result_var}), {r_val})");
312                        }
313                    }
314                    "not_empty" => {
315                        let _ = writeln!(out, "  expect_gt(length({result_var}), 0)");
316                    }
317                    "is_empty" => {
318                        let _ = writeln!(out, "  expect_equal(length({result_var}), 0)");
319                    }
320                    _ => {
321                        let _ = writeln!(
322                            out,
323                            "  # skipped: unsupported assertion type on synthetic field 'embeddings'"
324                        );
325                    }
326                }
327                return;
328            }
329            "embedding_dimensions" => {
330                let expr = format!("(if (length({result_var}) == 0) 0L else length({result_var}[[1]]))");
331                match assertion.assertion_type.as_str() {
332                    "equals" => {
333                        if let Some(val) = &assertion.value {
334                            let r_val = json_to_r(val, false);
335                            let _ = writeln!(out, "  expect_equal({expr}, {r_val})");
336                        }
337                    }
338                    "greater_than" => {
339                        if let Some(val) = &assertion.value {
340                            let r_val = json_to_r(val, false);
341                            let _ = writeln!(out, "  expect_gt({expr}, {r_val})");
342                        }
343                    }
344                    _ => {
345                        let _ = writeln!(
346                            out,
347                            "  # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
348                        );
349                    }
350                }
351                return;
352            }
353            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
354                let pred = match f.as_str() {
355                    "embeddings_valid" => {
356                        format!("all(sapply({result_var}, function(e) length(e) > 0))")
357                    }
358                    "embeddings_finite" => {
359                        format!("all(sapply({result_var}, function(e) all(is.finite(e))))")
360                    }
361                    "embeddings_non_zero" => {
362                        format!("all(sapply({result_var}, function(e) any(e != 0.0)))")
363                    }
364                    "embeddings_normalized" => {
365                        format!("all(sapply({result_var}, function(e) abs(sum(e * e) - 1.0) < 1e-3))")
366                    }
367                    _ => unreachable!(),
368                };
369                match assertion.assertion_type.as_str() {
370                    "is_true" => {
371                        let _ = writeln!(out, "  expect_true({pred})");
372                    }
373                    "is_false" => {
374                        let _ = writeln!(out, "  expect_false({pred})");
375                    }
376                    _ => {
377                        let _ = writeln!(out, "  # skipped: unsupported assertion type on synthetic field '{f}'");
378                    }
379                }
380                return;
381            }
382            // ---- keywords / keywords_count ----
383            // R ExtractionResult does not expose extracted_keywords; skip.
384            "keywords" | "keywords_count" => {
385                let _ = writeln!(out, "  # skipped: field '{f}' not available on R ExtractionResult");
386                return;
387            }
388            _ => {}
389        }
390    }
391
392    // Skip assertions on fields that don't exist on the result type.
393    if let Some(f) = &assertion.field {
394        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
395            let _ = writeln!(out, "  # skipped: field '{f}' not available on result type");
396            return;
397        }
398    }
399
400    // When result_is_simple, skip assertions that reference non-content fields
401    // (e.g., metadata, document, structure) since the binding returns a plain value.
402    if result_is_simple {
403        if let Some(f) = &assertion.field {
404            let f_lower = f.to_lowercase();
405            if !f.is_empty()
406                && f_lower != "content"
407                && (f_lower.starts_with("metadata")
408                    || f_lower.starts_with("document")
409                    || f_lower.starts_with("structure"))
410            {
411                let _ = writeln!(
412                    out,
413                    "  # skipped: result_is_simple for field '{f}' not available on result type"
414                );
415                return;
416            }
417        }
418    }
419
420    let field_expr = if result_is_simple {
421        result_var.to_string()
422    } else {
423        match &assertion.field {
424            Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
425            _ => result_var.to_string(),
426        }
427    };
428
429    match assertion.assertion_type.as_str() {
430        "equals" => {
431            if let Some(expected) = &assertion.value {
432                let r_val = json_to_r(expected, false);
433                let _ = writeln!(out, "  expect_equal(trimws({field_expr}), {r_val})");
434            }
435        }
436        "contains" => {
437            if let Some(expected) = &assertion.value {
438                let r_val = json_to_r(expected, false);
439                let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
440            }
441        }
442        "contains_all" => {
443            if let Some(values) = &assertion.values {
444                for val in values {
445                    let r_val = json_to_r(val, false);
446                    let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
447                }
448            }
449        }
450        "not_contains" => {
451            if let Some(expected) = &assertion.value {
452                let r_val = json_to_r(expected, false);
453                let _ = writeln!(out, "  expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
454            }
455        }
456        "not_empty" => {
457            let _ = writeln!(
458                out,
459                "  expect_true(if (is.character({field_expr})) nchar({field_expr}) > 0 else length({field_expr}) > 0)"
460            );
461        }
462        "is_empty" => {
463            let _ = writeln!(out, "  expect_equal({field_expr}, \"\")");
464        }
465        "contains_any" => {
466            if let Some(values) = &assertion.values {
467                let items: Vec<String> = values.iter().map(|v| json_to_r(v, false)).collect();
468                let vec_str = items.join(", ");
469                let _ = writeln!(
470                    out,
471                    "  expect_true(any(sapply(c({vec_str}), function(v) grepl(v, {field_expr}, fixed = TRUE))))"
472                );
473            }
474        }
475        "greater_than" => {
476            if let Some(val) = &assertion.value {
477                let r_val = json_to_r(val, false);
478                let _ = writeln!(out, "  expect_true({field_expr} > {r_val})");
479            }
480        }
481        "less_than" => {
482            if let Some(val) = &assertion.value {
483                let r_val = json_to_r(val, false);
484                let _ = writeln!(out, "  expect_true({field_expr} < {r_val})");
485            }
486        }
487        "greater_than_or_equal" => {
488            if let Some(val) = &assertion.value {
489                let r_val = json_to_r(val, false);
490                let _ = writeln!(out, "  expect_true({field_expr} >= {r_val})");
491            }
492        }
493        "less_than_or_equal" => {
494            if let Some(val) = &assertion.value {
495                let r_val = json_to_r(val, false);
496                let _ = writeln!(out, "  expect_true({field_expr} <= {r_val})");
497            }
498        }
499        "starts_with" => {
500            if let Some(expected) = &assertion.value {
501                let r_val = json_to_r(expected, false);
502                let _ = writeln!(out, "  expect_true(startsWith({field_expr}, {r_val}))");
503            }
504        }
505        "ends_with" => {
506            if let Some(expected) = &assertion.value {
507                let r_val = json_to_r(expected, false);
508                let _ = writeln!(out, "  expect_true(endsWith({field_expr}, {r_val}))");
509            }
510        }
511        "min_length" => {
512            if let Some(val) = &assertion.value {
513                if let Some(n) = val.as_u64() {
514                    let _ = writeln!(out, "  expect_true(nchar({field_expr}) >= {n})");
515                }
516            }
517        }
518        "max_length" => {
519            if let Some(val) = &assertion.value {
520                if let Some(n) = val.as_u64() {
521                    let _ = writeln!(out, "  expect_true(nchar({field_expr}) <= {n})");
522                }
523            }
524        }
525        "count_min" => {
526            if let Some(val) = &assertion.value {
527                if let Some(n) = val.as_u64() {
528                    let _ = writeln!(out, "  expect_true(length({field_expr}) >= {n})");
529                }
530            }
531        }
532        "count_equals" => {
533            if let Some(val) = &assertion.value {
534                if let Some(n) = val.as_u64() {
535                    let _ = writeln!(out, "  expect_equal(length({field_expr}), {n})");
536                }
537            }
538        }
539        "is_true" => {
540            let _ = writeln!(out, "  expect_true({field_expr})");
541        }
542        "is_false" => {
543            let _ = writeln!(out, "  expect_false({field_expr})");
544        }
545        "method_result" => {
546            if let Some(method_name) = &assertion.method {
547                let call_expr = build_r_method_call(result_var, method_name, assertion.args.as_ref());
548                let check = assertion.check.as_deref().unwrap_or("is_true");
549                match check {
550                    "equals" => {
551                        if let Some(val) = &assertion.value {
552                            if val.is_boolean() {
553                                if val.as_bool() == Some(true) {
554                                    let _ = writeln!(out, "  expect_true({call_expr})");
555                                } else {
556                                    let _ = writeln!(out, "  expect_false({call_expr})");
557                                }
558                            } else {
559                                let r_val = json_to_r(val, false);
560                                let _ = writeln!(out, "  expect_equal({call_expr}, {r_val})");
561                            }
562                        }
563                    }
564                    "is_true" => {
565                        let _ = writeln!(out, "  expect_true({call_expr})");
566                    }
567                    "is_false" => {
568                        let _ = writeln!(out, "  expect_false({call_expr})");
569                    }
570                    "greater_than_or_equal" => {
571                        if let Some(val) = &assertion.value {
572                            let r_val = json_to_r(val, false);
573                            let _ = writeln!(out, "  expect_true({call_expr} >= {r_val})");
574                        }
575                    }
576                    "count_min" => {
577                        if let Some(val) = &assertion.value {
578                            let n = val.as_u64().unwrap_or(0);
579                            let _ = writeln!(out, "  expect_true(length({call_expr}) >= {n})");
580                        }
581                    }
582                    "is_error" => {
583                        let _ = writeln!(out, "  expect_error({call_expr})");
584                    }
585                    "contains" => {
586                        if let Some(val) = &assertion.value {
587                            let r_val = json_to_r(val, false);
588                            let _ = writeln!(out, "  expect_true(grepl({r_val}, {call_expr}, fixed = TRUE))");
589                        }
590                    }
591                    other_check => {
592                        panic!("R e2e generator: unsupported method_result check type: {other_check}");
593                    }
594                }
595            } else {
596                panic!("R e2e generator: method_result assertion missing 'method' field");
597            }
598        }
599        "matches_regex" => {
600            if let Some(expected) = &assertion.value {
601                let r_val = json_to_r(expected, false);
602                let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}))");
603            }
604        }
605        "not_error" => {
606            // Already handled — the call would stop on error.
607        }
608        "error" => {
609            // Handled at the test level.
610        }
611        other => {
612            panic!("R e2e generator: unsupported assertion type: {other}");
613        }
614    }
615}
616
617/// Convert a `serde_json::Value` to an R literal string.
618///
619/// # Arguments
620///
621/// * `value` - The JSON value to convert
622/// * `lowercase_enum_values` - If true, lowercase strings starting with uppercase letter (for enum values).
623///   If false, preserve original case (for assertion expected values).
624fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
625    match value {
626        serde_json::Value::String(s) => {
627            // Lowercase enum values (strings starting with uppercase letter) only if requested
628            let normalized = if lowercase_enum_values && s.chars().next().is_some_and(|c| c.is_uppercase()) {
629                s.to_lowercase()
630            } else {
631                s.clone()
632            };
633            format!("\"{}\"", escape_r(&normalized))
634        }
635        serde_json::Value::Bool(true) => "TRUE".to_string(),
636        serde_json::Value::Bool(false) => "FALSE".to_string(),
637        serde_json::Value::Number(n) => n.to_string(),
638        serde_json::Value::Null => "NULL".to_string(),
639        serde_json::Value::Array(arr) => {
640            let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
641            format!("c({})", items.join(", "))
642        }
643        serde_json::Value::Object(map) => {
644            let entries: Vec<String> = map
645                .iter()
646                .map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v, lowercase_enum_values)))
647                .collect();
648            format!("list({})", entries.join(", "))
649        }
650    }
651}
652
653/// Build an R visitor list and add setup line.
654fn build_r_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
655    use std::fmt::Write as FmtWrite;
656    let mut visitor_obj = String::new();
657    let _ = writeln!(visitor_obj, "list(");
658    for (method_name, action) in &visitor_spec.callbacks {
659        emit_r_visitor_method(&mut visitor_obj, method_name, action);
660    }
661    let _ = writeln!(visitor_obj, "  )");
662
663    setup_lines.push(format!("visitor <- {visitor_obj}"));
664}
665
666/// Build an R call expression for a `method_result` assertion.
667/// Maps method names to the appropriate R function or method calls.
668fn build_r_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
669    match method_name {
670        "root_child_count" => format!("{result_var}$root_child_count()"),
671        "root_node_type" => format!("{result_var}$root_node_type()"),
672        "named_children_count" => format!("{result_var}$named_children_count()"),
673        "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
674        "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
675        "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
676        "contains_node_type" => {
677            let node_type = args
678                .and_then(|a| a.get("node_type"))
679                .and_then(|v| v.as_str())
680                .unwrap_or("");
681            format!("tree_contains_node_type({result_var}, \"{node_type}\")")
682        }
683        "find_nodes_by_type" => {
684            let node_type = args
685                .and_then(|a| a.get("node_type"))
686                .and_then(|v| v.as_str())
687                .unwrap_or("");
688            format!("find_nodes_by_type({result_var}, \"{node_type}\")")
689        }
690        "run_query" => {
691            let query_source = args
692                .and_then(|a| a.get("query_source"))
693                .and_then(|v| v.as_str())
694                .unwrap_or("");
695            let language = args
696                .and_then(|a| a.get("language"))
697                .and_then(|v| v.as_str())
698                .unwrap_or("");
699            format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
700        }
701        _ => {
702            if let Some(args_val) = args {
703                let arg_str = args_val
704                    .as_object()
705                    .map(|obj| {
706                        obj.iter()
707                            .map(|(k, v)| {
708                                let r_val = json_to_r(v, false);
709                                format!("{k} = {r_val}")
710                            })
711                            .collect::<Vec<_>>()
712                            .join(", ")
713                    })
714                    .unwrap_or_default();
715                format!("{result_var}${method_name}({arg_str})")
716            } else {
717                format!("{result_var}${method_name}()")
718            }
719        }
720    }
721}
722
723/// Emit an R visitor method for a callback action.
724fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
725    use std::fmt::Write as FmtWrite;
726
727    // R uses visit_ prefix (matches binding signature)
728    let params = match method_name {
729        "visit_link" => "ctx, href, text, title",
730        "visit_image" => "ctx, src, alt, title",
731        "visit_heading" => "ctx, level, text, id",
732        "visit_code_block" => "ctx, lang, code",
733        "visit_code_inline"
734        | "visit_strong"
735        | "visit_emphasis"
736        | "visit_strikethrough"
737        | "visit_underline"
738        | "visit_subscript"
739        | "visit_superscript"
740        | "visit_mark"
741        | "visit_button"
742        | "visit_summary"
743        | "visit_figcaption"
744        | "visit_definition_term"
745        | "visit_definition_description" => "ctx, text",
746        "visit_text" => "ctx, text",
747        "visit_list_item" => "ctx, ordered, marker, text",
748        "visit_blockquote" => "ctx, content, depth",
749        "visit_table_row" => "ctx, cells, is_header",
750        "visit_custom_element" => "ctx, tag_name, html",
751        "visit_form" => "ctx, action_url, method",
752        "visit_input" => "ctx, input_type, name, value",
753        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
754        "visit_details" => "ctx, is_open",
755        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
756        "visit_list_start" => "ctx, ordered",
757        "visit_list_end" => "ctx, ordered, output",
758        _ => "ctx",
759    };
760
761    let _ = writeln!(out, "    {method_name} = function({params}) {{");
762    match action {
763        CallbackAction::Skip => {
764            let _ = writeln!(out, "      \"skip\"");
765        }
766        CallbackAction::Continue => {
767            let _ = writeln!(out, "      \"continue\"");
768        }
769        CallbackAction::PreserveHtml => {
770            let _ = writeln!(out, "      \"preserve_html\"");
771        }
772        CallbackAction::Custom { output } => {
773            let escaped = escape_r(output);
774            let _ = writeln!(out, "      list(custom = {escaped})");
775        }
776        CallbackAction::CustomTemplate { template } => {
777            let escaped = escape_r(template);
778            let _ = writeln!(out, "      list(custom = {escaped})");
779        }
780    }
781    let _ = writeln!(out, "    }},");
782}