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, r_template_to_paste0, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, TemplateReturnForm};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::ResolvedCrateConfig;
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        config: &ResolvedCrateConfig,
25        _type_defs: &[alef_core::ir::TypeDef],
26        _enums: &[alef_core::ir::EnumDef],
27    ) -> Result<Vec<GeneratedFile>> {
28        let lang = self.language_name();
29        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
30
31        let mut files = Vec::new();
32
33        // Resolve call config with overrides.
34        let call = &e2e_config.call;
35        let overrides = call.overrides.get(lang);
36        let module_path = overrides
37            .and_then(|o| o.module.as_ref())
38            .cloned()
39            .unwrap_or_else(|| call.module.clone());
40        let _function_name = overrides
41            .and_then(|o| o.function.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.function.clone());
44        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
45        let result_is_r_list = overrides.is_some_and(|o| o.result_is_r_list);
46        let _result_var = &call.result_var;
47
48        // Resolve package config.
49        let r_pkg = e2e_config.resolve_package("r");
50        let pkg_name = r_pkg
51            .as_ref()
52            .and_then(|p| p.name.as_ref())
53            .cloned()
54            .unwrap_or_else(|| module_path.clone());
55        let pkg_path = r_pkg
56            .as_ref()
57            .and_then(|p| p.path.as_ref())
58            .cloned()
59            .unwrap_or_else(|| "../../packages/r".to_string());
60        let pkg_version = r_pkg
61            .as_ref()
62            .and_then(|p| p.version.as_ref())
63            .cloned()
64            .or_else(|| config.resolved_version())
65            .unwrap_or_else(|| "0.1.0".to_string());
66
67        // Generate DESCRIPTION file.
68        files.push(GeneratedFile {
69            path: output_base.join("DESCRIPTION"),
70            content: render_description(&pkg_name, &pkg_version, e2e_config.dep_mode),
71            generated_header: false,
72        });
73
74        // Generate test runner script.
75        files.push(GeneratedFile {
76            path: output_base.join("run_tests.R"),
77            content: render_test_runner(&pkg_path, e2e_config.dep_mode),
78            generated_header: true,
79        });
80
81        // setup-fixtures.R — testthat sources `setup-*.R` files in the tests
82        // directory once before any tests run, with the working directory set
83        // to the tests/ folder. We use this hook to chdir into the repo's
84        // shared `test_documents/` directory so that fixture paths like
85        // `pdf/fake_memo.pdf` resolve at extraction time.
86        files.push(GeneratedFile {
87            path: output_base.join("tests").join("setup-fixtures.R"),
88            content: render_setup_fixtures(&e2e_config.test_documents_relative_from(1)),
89            generated_header: true,
90        });
91
92        // Generate test files per category.
93        for group in groups {
94            let active: Vec<&Fixture> = group
95                .fixtures
96                .iter()
97                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
98                .collect();
99
100            if active.is_empty() {
101                continue;
102            }
103
104            let filename = format!("test_{}.R", sanitize_filename(&group.category));
105            let content = render_test_file(&group.category, &active, result_is_simple, result_is_r_list, e2e_config);
106            files.push(GeneratedFile {
107                path: output_base.join("tests").join(filename),
108                content,
109                generated_header: true,
110            });
111        }
112
113        Ok(files)
114    }
115
116    fn language_name(&self) -> &'static str {
117        "r"
118    }
119}
120
121fn render_description(pkg_name: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
122    let dep_line = match dep_mode {
123        crate::config::DependencyMode::Registry => {
124            format!("Imports: {pkg_name} ({pkg_version})\n")
125        }
126        crate::config::DependencyMode::Local => String::new(),
127    };
128    format!(
129        r#"Package: e2e.r
130Title: E2E Tests for {pkg_name}
131Version: 0.1.0
132Description: End-to-end test suite.
133{dep_line}Suggests: testthat (>= 3.0.0)
134Config/testthat/edition: 3
135"#
136    )
137}
138
139fn render_setup_fixtures(test_documents_path: &str) -> String {
140    let mut out = String::new();
141    out.push_str(&hash::header(CommentStyle::Hash));
142    let _ = writeln!(out);
143    let _ = writeln!(
144        out,
145        "# Resolve fixture paths against the repo's `test_documents/` directory."
146    );
147    let _ = writeln!(
148        out,
149        "# testthat sources setup-*.R with the working directory at tests/,"
150    );
151    let _ = writeln!(
152        out,
153        "# so test_documents lives three directories up: tests/ -> e2e/r/ -> e2e/ -> repo root."
154    );
155    let _ = writeln!(
156        out,
157        "# Each `test_that()` block has its working directory reset back to tests/, so"
158    );
159    let _ = writeln!(
160        out,
161        "# fixture lookups must be performed via this helper rather than relying on `setwd`."
162    );
163    let _ = writeln!(
164        out,
165        ".alef_test_documents <- normalizePath(\"{test_documents_path}\", mustWork = FALSE)"
166    );
167    let _ = writeln!(out, ".resolve_fixture <- function(path) {{");
168    let _ = writeln!(out, "  if (dir.exists(.alef_test_documents)) {{");
169    let _ = writeln!(out, "    file.path(.alef_test_documents, path)");
170    let _ = writeln!(out, "  }} else {{");
171    let _ = writeln!(out, "    path");
172    let _ = writeln!(out, "  }}");
173    let _ = writeln!(out, "}}");
174    out
175}
176
177fn render_test_runner(pkg_path: &str, dep_mode: crate::config::DependencyMode) -> String {
178    let mut out = String::new();
179    out.push_str(&hash::header(CommentStyle::Hash));
180    let _ = writeln!(out, "library(testthat)");
181    match dep_mode {
182        crate::config::DependencyMode::Registry => {
183            // In registry mode, require the installed CRAN package directly.
184            let _ = writeln!(out, "# Package loaded via library() from CRAN install.");
185        }
186        crate::config::DependencyMode::Local => {
187            // Use devtools::load_all() to load the local R package without requiring
188            // a full install, matching the e2e test runner convention.
189            let _ = writeln!(out, "devtools::load_all(\"{pkg_path}\")");
190        }
191    }
192    let _ = writeln!(out);
193    // Surface every failure rather than aborting at the default max_fails=10 —
194    // partial pass counts are essential for triage during e2e bring-up.
195    let _ = writeln!(out, "testthat::set_max_fails(Inf)");
196    // Resolve the tests/ directory relative to this script. testthat reads
197    // setup-*.R from there before each file runs, where path resolution
198    // against test_documents/ is handled by the `.resolve_fixture` helper.
199    let _ = writeln!(
200        out,
201        ".script_dir <- tryCatch(dirname(normalizePath(sys.frame(1)$ofile)), error = function(e) getwd())"
202    );
203    let _ = writeln!(out, "test_dir(file.path(.script_dir, \"tests\"))");
204    out
205}
206
207fn render_test_file(
208    category: &str,
209    fixtures: &[&Fixture],
210    result_is_simple: bool,
211    result_is_r_list: bool,
212    e2e_config: &E2eConfig,
213) -> String {
214    let mut out = String::new();
215    out.push_str(&hash::header(CommentStyle::Hash));
216    let _ = writeln!(out, "# E2e tests for category: {category}");
217    let _ = writeln!(out);
218
219    for (i, fixture) in fixtures.iter().enumerate() {
220        render_test_case(&mut out, fixture, e2e_config, result_is_simple, result_is_r_list);
221        if i + 1 < fixtures.len() {
222            let _ = writeln!(out);
223        }
224    }
225
226    // Clean up trailing newlines.
227    while out.ends_with("\n\n") {
228        out.pop();
229    }
230    if !out.ends_with('\n') {
231        out.push('\n');
232    }
233    out
234}
235
236fn render_test_case(
237    out: &mut String,
238    fixture: &Fixture,
239    e2e_config: &E2eConfig,
240    default_result_is_simple: bool,
241    default_result_is_r_list: bool,
242) {
243    let call_config = e2e_config.resolve_call_for_fixture(
244        fixture.call.as_deref(),
245        &fixture.id,
246        &fixture.resolved_category(),
247        &fixture.tags,
248        &fixture.input,
249    );
250    let call_field_resolver = FieldResolver::new(
251        e2e_config.effective_fields(call_config),
252        e2e_config.effective_fields_optional(call_config),
253        e2e_config.effective_result_fields(call_config),
254        e2e_config.effective_fields_array(call_config),
255        &std::collections::HashSet::new(),
256    );
257    let field_resolver = &call_field_resolver;
258    // Resolve `function` via the R override when present. The default
259    // `call_config.function` can be empty (e.g. trait-bridge calls like
260    // `clear_document_extractors` set `function = ""` at the top level and
261    // expose the real binding name only through per-language overrides);
262    // emitting it verbatim produces invalid `result <- ()` calls.
263    let function_name = call_config
264        .overrides
265        .get("r")
266        .and_then(|o| o.function.as_ref())
267        .cloned()
268        .unwrap_or_else(|| call_config.function.clone());
269    let result_var = &call_config.result_var;
270    // Per-fixture call configs (e.g. `list_document_extractors`) may set
271    // `result_is_simple = true` even when the default `[e2e.call]` does not.
272    // Without this lookup the registry/detection wrappers (which return scalar
273    // strings or character vectors directly) get wrapped in
274    // `jsonlite::fromJSON(...)` and the parser fails on non-JSON output.
275    let r_override = call_config.overrides.get("r");
276    let result_is_simple = if fixture.call.is_some() {
277        call_config.result_is_simple || r_override.is_some_and(|o| o.result_is_simple)
278    } else {
279        default_result_is_simple
280    };
281    // Per-fixture override: when the R binding already returns a native R list
282    // (not a JSON string), suppress `jsonlite::fromJSON` wrapping while still
283    // using field-path (`result$field`) accessors in assertions.
284    let result_is_r_list = if fixture.call.is_some() {
285        r_override.is_some_and(|o| o.result_is_r_list)
286    } else {
287        default_result_is_r_list
288    };
289
290    let test_name = sanitize_ident(&fixture.id);
291    let description = fixture.description.replace('"', "\\\"");
292
293    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
294
295    // Allow per-call R overrides to remap fixture argument names. Many calls
296    // (e.g. `extract_bytes`, `batch_extract_files`) use language-neutral
297    // fixture field names (`data`, `paths`) that the R extendr binding
298    // exposes under different identifiers (`content`, `items`).
299    let arg_name_map = r_override.map(|o| &o.arg_name_map);
300    // Resolve `options_type` for typed config args. When set (e.g. via the
301    // C#/Java override that pins the `config` arg of `embed_texts` to
302    // `EmbeddingConfig`), we use it instead of the heuristic in
303    // `r_default_for_config_arg` so the extendr binding receives the right
304    // ExternalPtr type rather than a default `ExtractionConfig`.
305    let options_type = r_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
306        // Fall back to any other language's override that pins the type —
307        // R doesn't define its own override list yet for most embed calls,
308        // and the underlying Rust signature is the same regardless of
309        // binding, so reusing csharp/java/go/php options_type is safe.
310        //
311        // Skip `Js`-prefixed types from the Node/wasm bindings: those are
312        // NAPI/wasm-bindgen specific wrapper types, while extendr exposes the
313        // bare Rust type names (e.g. `ExtractionConfig`, not `JsExtractionConfig`).
314        call_config
315            .overrides
316            .values()
317            .filter_map(|o| o.options_type.as_deref())
318            .find(|name| !name.starts_with("Js"))
319    });
320    let args_str = build_args_string(&fixture.input, &call_config.args, arg_name_map, options_type);
321
322    // Build visitor setup and args if present
323    let mut setup_lines = Vec::new();
324    let final_args = if let Some(visitor_spec) = &fixture.visitor {
325        build_r_visitor(&mut setup_lines, visitor_spec);
326        // R rejects duplicated named arguments ("matched by multiple actual arguments"), so
327        // strip any existing `options = ...` arg before appending the visitor-options list.
328        // Handles `options = NULL` (when no default) and `options = ConversionOptions$default()`
329        // (when build_args_string emits a default placeholder for an optional options arg).
330        let base = strip_options_arg(&args_str);
331        let visitor_opts = "options = list(visitor = visitor)";
332        let trimmed = base.trim_matches([' ', ',']);
333        if trimmed.is_empty() {
334            visitor_opts.to_string()
335        } else {
336            format!("{trimmed}, {visitor_opts}")
337        }
338    } else {
339        args_str
340    };
341
342    if expects_error {
343        let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
344        for line in &setup_lines {
345            let _ = writeln!(out, "  {line}");
346        }
347        let _ = writeln!(out, "  expect_error({function_name}({final_args}))");
348        let _ = writeln!(out, "}})");
349        return;
350    }
351
352    let _ = writeln!(out, "test_that(\"{test_name}: {description}\", {{");
353    for line in &setup_lines {
354        let _ = writeln!(out, "  {line}");
355    }
356    // The extendr extraction wrappers return JSON strings carrying the
357    // serialized core result; parse into an R list so tests can use `$`
358    // accessors. `result_is_simple` calls (e.g. `convert_html_to_markdown`)
359    // already return scalar values and must be passed through verbatim.
360    // `result_is_r_list` signals the binding returns a native R list (Robj),
361    // not a JSON string — skip `jsonlite::fromJSON` but keep `$` accessors.
362    // `returns_void` calls (trait-bridge `clear_*` wrappers that return `()`
363    // in Rust → `NULL` in R) must not bind a `result` variable: the previous
364    // emission of `result <- {function_name}(...)` was already correct when
365    // `function_name` resolved, but parsers flag a stray `result` for void
366    // calls. Use `invisible(...)` to make the void contract explicit.
367    if call_config.returns_void {
368        let _ = writeln!(out, "  invisible({function_name}({final_args}))");
369    } else if result_is_simple || result_is_r_list {
370        let _ = writeln!(out, "  {result_var} <- {function_name}({final_args})");
371    } else {
372        let _ = writeln!(
373            out,
374            "  {result_var} <- jsonlite::fromJSON({function_name}({final_args}), simplifyVector = FALSE)"
375        );
376    }
377
378    for assertion in &fixture.assertions {
379        render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
380    }
381
382    let _ = writeln!(out, "}})");
383}
384
385/// Remove the named `options = …` argument (if any) from an R call-args string.
386///
387/// Walks the string while tracking paren/quote depth so a comma inside a nested
388/// expression like `options = list(visitor = visitor)` isn't treated as the
389/// arg terminator. Returns the rebuilt args string with the `options =` arg
390/// dropped; callers append a fresh one.
391fn strip_options_arg(args_str: &str) -> String {
392    let mut parts: Vec<String> = Vec::new();
393    let mut current = String::new();
394    let mut paren_depth: i32 = 0;
395    let mut in_single = false;
396    let mut in_double = false;
397    for c in args_str.chars() {
398        if !in_single && !in_double {
399            match c {
400                '(' | '[' | '{' => paren_depth += 1,
401                ')' | ']' | '}' => paren_depth -= 1,
402                '\'' => in_single = true,
403                '"' => in_double = true,
404                ',' if paren_depth == 0 => {
405                    parts.push(current.trim().to_string());
406                    current.clear();
407                    continue;
408                }
409                _ => {}
410            }
411        } else if in_single && c == '\'' {
412            in_single = false;
413        } else if in_double && c == '"' {
414            in_double = false;
415        }
416        current.push(c);
417    }
418    if !current.trim().is_empty() {
419        parts.push(current.trim().to_string());
420    }
421    parts
422        .into_iter()
423        .filter(|p| !p.starts_with("options ") && !p.starts_with("options="))
424        .collect::<Vec<_>>()
425        .join(", ")
426}
427
428fn build_args_string(
429    input: &serde_json::Value,
430    args: &[crate::config::ArgMapping],
431    arg_name_map: Option<&std::collections::HashMap<String, String>>,
432    options_type: Option<&str>,
433) -> String {
434    if args.is_empty() {
435        // No declared args means the wrapper takes zero parameters; emitting
436        // `list()` here would trigger an `unused argument (list())` error in R.
437        // Likewise, fall through to nothing if the fixture's input is empty.
438        if matches!(input, serde_json::Value::Null) || input.as_object().is_some_and(|m| m.is_empty()) {
439            return String::new();
440        }
441        return json_to_r(input, true);
442    }
443
444    let parts: Vec<String> = args
445        .iter()
446        .filter_map(|arg| {
447            // Apply per-language argument renames before emitting the call.
448            let arg_name: &str = arg_name_map
449                .and_then(|m| m.get(&arg.name).map(String::as_str))
450                .unwrap_or(&arg.name);
451
452            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
453            let val = input.get(field);
454            // R extendr-generated wrappers do not preserve Option<T> defaults from
455            // the Rust signature — every parameter is positional and required at
456            // the R level. To keep generated calls valid we must pass a placeholder
457            // (`NULL` for `Option<T>`, `ExtractionConfig$default()` for typed
458            // configs) whenever the fixture omits an optional value.
459            let val = match val {
460                Some(v) if !(v.is_null() && arg.optional) => v,
461                _ => {
462                    if !arg.optional {
463                        return None;
464                    }
465                    if arg.arg_type == "json_object" {
466                        let r_value = r_default_for_config_arg(arg_name, options_type);
467                        return Some(format!("{arg_name} = {r_value}"));
468                    }
469                    return Some(format!("{arg_name} = NULL"));
470                }
471            };
472            // The extendr bindings expect owned PORs (ExternalPtr) for typed
473            // config arguments — passing an R `list()` raises
474            // `Expected ExternalPtr got List`. The fixtures don't carry the
475            // option fields needed to round-trip through ExtractionConfig$new,
476            // so emit `ExtractionConfig$default()` whenever a `json_object` arg
477            // resolves to an empty / object-shaped JSON value.
478            if arg.arg_type == "json_object" && (val.is_null() || val.as_object().is_some_and(|m| m.is_empty())) {
479                let r_value = r_default_for_config_arg(arg_name, options_type);
480                return Some(format!("{arg_name} = {r_value}"));
481            }
482            // Non-empty json_object for typed config args (those whose default is a
483            // `$default()` constructor): use `TypeName$from_json(jsonlite::toJSON(...))`
484            // so the Rust function receives a proper ExternalPtr, not a list.
485            // For `options`-style args (default = NULL) emit as a plain R list.
486            if arg.arg_type == "json_object" && val.is_object() {
487                let default_expr = r_default_for_config_arg(arg_name, options_type);
488                if default_expr.ends_with("$default()") {
489                    // Extract the type name from "TypeName$default()"
490                    let type_name = default_expr.trim_end_matches("$default()");
491                    // Use the `I(...)` (AsIs) wrapper for array-valued fields so
492                    // `jsonlite::toJSON(..., auto_unbox = TRUE)` preserves them as
493                    // JSON arrays. Without this, single-element vectors get
494                    // unboxed to scalars (e.g. `c("foo")` → `"foo"`) and serde
495                    // rejects them when deserializing `Vec<T>` fields.
496                    let r_list = json_to_r_preserve_arrays(val, true);
497                    let r_value = format!("{type_name}$from_json(jsonlite::toJSON({r_list}, auto_unbox = TRUE))");
498                    return Some(format!("{arg_name} = {r_value}"));
499                }
500                let r_value = json_to_r(val, true);
501                return Some(format!("{arg_name} = {r_value}"));
502            }
503            // `json_object` arrays are passed to extendr functions whose Rust
504            // signature is `items: String` (JSON-serialized batch items). The
505            // wrapper has no R-list → JSON conversion, so we must serialize the
506            // fixture value to a literal JSON string at test-emit time.
507            //
508            // Exception: when `element_type = "String"` the Rust signature is
509            // `Vec<String>` (e.g. `embed_texts(texts: Vec<String>, ...)`), which
510            // extendr binds as a native R character vector. Passing a JSON
511            // literal there would land as a single-element character vector
512            // containing the literal bytes `["a","b"]`, which is not what the
513            // caller intended. Emit a plain `c("a","b")` literal instead.
514            if arg.arg_type == "json_object" && val.is_array() {
515                if arg.element_type.as_deref() == Some("String") {
516                    let r_value = json_to_r(val, false);
517                    return Some(format!("{arg_name} = {r_value}"));
518                }
519                let json_literal = serde_json::to_string(val).unwrap_or_else(|_| "[]".to_string());
520                let escaped = escape_r(&json_literal);
521                return Some(format!("{arg_name} = \"{escaped}\""));
522            }
523            // `bytes` arg type: convert string fixture values into runtime
524            // `readBin(...)` calls so the wrapper receives raw bytes instead
525            // of an R character vector. This mirrors the Python emit_bytes_arg
526            // helper and is what the extendr binding for Vec<u8> expects.
527            if arg.arg_type == "bytes" {
528                if let Some(raw) = val.as_str() {
529                    let r_value = render_bytes_value(raw);
530                    return Some(format!("{arg_name} = {r_value}"));
531                }
532            }
533            // `file_path` arg type: fixtures encode relative paths that resolve
534            // against the repo's `test_documents/` directory. Using a runtime
535            // helper that anchors paths to that directory avoids fragility from
536            // testthat resetting the working directory between files.
537            if arg.arg_type == "file_path" {
538                if let Some(raw) = val.as_str() {
539                    if !raw.starts_with('/') && !raw.is_empty() {
540                        let escaped = escape_r(raw);
541                        return Some(format!("{arg_name} = .resolve_fixture(\"{escaped}\")"));
542                    }
543                }
544            }
545            Some(format!("{arg_name} = {}", json_to_r(val, true)))
546        })
547        .collect();
548
549    parts.join(", ")
550}
551
552/// Render a `bytes` fixture value as the R expression that produces a raw
553/// vector at test time. Mirrors python's `emit_bytes_arg` classifier so we can
554/// support both file-path style fixtures (`"pdf/fake_memo.pdf"`) and inline
555/// text payloads (`"<html>..."`). The resulting expression is dropped directly
556/// into the call site, e.g. `content = readBin(.resolve_fixture("pdf/fake_memo.pdf"), ...)`.
557fn render_bytes_value(raw: &str) -> String {
558    if raw.starts_with('<') || raw.starts_with('{') || raw.starts_with('[') || raw.contains(' ') {
559        // Inline text payload — encode to raw via charToRaw.
560        let escaped = escape_r(raw);
561        return format!("charToRaw(\"{escaped}\")");
562    }
563    let first = raw.chars().next().unwrap_or('\0');
564    if first.is_ascii_alphanumeric() || first == '_' {
565        if let Some(slash) = raw.find('/') {
566            if slash > 0 {
567                let after = &raw[slash + 1..];
568                if after.contains('.') && !after.is_empty() {
569                    let escaped = escape_r(raw);
570                    return format!(
571                        "readBin(.resolve_fixture(\"{escaped}\"), what = \"raw\", n = file.info(.resolve_fixture(\"{escaped}\"))$size)"
572                    );
573                }
574            }
575        }
576    }
577    // Default to inline text encoding — matches Python's InlineText branch.
578    let escaped = escape_r(raw);
579    format!("charToRaw(\"{escaped}\")")
580}
581
582/// Map the extractor argument name onto its R `*Config$default()` constructor.
583/// Falls back to `list()` for unknown names — the extendr binding will error
584/// with a clear message, which is preferable to silently passing a wrong type.
585///
586/// When `options_type` is provided (via a per-call language override pinning
587/// the typed config, e.g. `EmbeddingConfig` for `embed_texts`), it takes
588/// precedence over the arg-name heuristic so the extendr binding receives the
589/// correct ExternalPtr type.
590fn r_default_for_config_arg(arg_name: &str, options_type: Option<&str>) -> String {
591    if let Some(type_name) = options_type {
592        return format!("{type_name}$default()");
593    }
594    match arg_name {
595        "config" => "ExtractionConfig$default()".to_string(),
596        "options" => "NULL".to_string(),
597        "html_output" => "HtmlOutputConfig$default()".to_string(),
598        "chunking" => "ChunkingConfig$default()".to_string(),
599        "ocr" => "OcrConfig$default()".to_string(),
600        "image" | "images" => "ImageExtractionConfig$default()".to_string(),
601        "language_detection" => "LanguageDetectionConfig$default()".to_string(),
602        _ => "list()".to_string(),
603    }
604}
605
606fn render_assertion(
607    out: &mut String,
608    assertion: &Assertion,
609    result_var: &str,
610    field_resolver: &FieldResolver,
611    result_is_simple: bool,
612    _e2e_config: &E2eConfig,
613) {
614    // Handle synthetic / derived fields before the is_valid_for_result check
615    // so they are never treated as struct attribute accesses on the result.
616    if let Some(f) = &assertion.field {
617        match f.as_str() {
618            "chunks_have_content" => {
619                let pred = format!("all(sapply({result_var}$chunks %||% list(), function(c) nchar(c$content) > 0))");
620                match assertion.assertion_type.as_str() {
621                    "is_true" => {
622                        let _ = writeln!(out, "  expect_true({pred})");
623                    }
624                    "is_false" => {
625                        let _ = writeln!(out, "  expect_false({pred})");
626                    }
627                    _ => {
628                        let _ = writeln!(out, "  # skipped: unsupported assertion type on synthetic field '{f}'");
629                    }
630                }
631                return;
632            }
633            "chunks_have_embeddings" => {
634                let pred = format!(
635                    "all(sapply({result_var}$chunks %||% list(), function(c) !is.null(c$embedding) && length(c$embedding) > 0))"
636                );
637                match assertion.assertion_type.as_str() {
638                    "is_true" => {
639                        let _ = writeln!(out, "  expect_true({pred})");
640                    }
641                    "is_false" => {
642                        let _ = writeln!(out, "  expect_false({pred})");
643                    }
644                    _ => {
645                        let _ = writeln!(out, "  # skipped: unsupported assertion type on synthetic field '{f}'");
646                    }
647                }
648                return;
649            }
650            "chunks_have_heading_context" => {
651                // prepend_heading_context adds heading text to chunk content, so verify chunks
652                // exist and every chunk has non-empty content.
653                let pred_true = format!(
654                    "!is.null({result_var}$chunks) && length({result_var}$chunks) > 0 && all(sapply({result_var}$chunks, function(c) nchar(c$content) > 0))"
655                );
656                let pred_false = format!("is.null({result_var}$chunks) || length({result_var}$chunks) == 0");
657                match assertion.assertion_type.as_str() {
658                    "is_true" => {
659                        let _ = writeln!(out, "  expect_true({pred_true})");
660                    }
661                    "is_false" => {
662                        let _ = writeln!(out, "  expect_true({pred_false})");
663                    }
664                    _ => {
665                        let _ = writeln!(out, "  # skipped: unsupported assertion type on synthetic field '{f}'");
666                    }
667                }
668                return;
669            }
670            "first_chunk_starts_with_heading" => {
671                // First chunk's content should start with a markdown heading marker (`#`)
672                // when prepend_heading_context is enabled.
673                let pred_true = format!(
674                    "!is.null({result_var}$chunks) && length({result_var}$chunks) > 0 && startsWith(trimws({result_var}$chunks[[1]]$content), \"#\")"
675                );
676                let pred_false = format!(
677                    "is.null({result_var}$chunks) || length({result_var}$chunks) == 0 || !startsWith(trimws({result_var}$chunks[[1]]$content), \"#\")"
678                );
679                match assertion.assertion_type.as_str() {
680                    "is_true" => {
681                        let _ = writeln!(out, "  expect_true({pred_true})");
682                    }
683                    "is_false" => {
684                        let _ = writeln!(out, "  expect_true({pred_false})");
685                    }
686                    _ => {
687                        let _ = writeln!(out, "  # skipped: unsupported assertion type on synthetic field '{f}'");
688                    }
689                }
690                return;
691            }
692            // ---- EmbedResponse virtual fields ----
693            // The extendr binding cannot return `Vec<Vec<f32>>` directly (extendr's
694            // Robj conversion has no impl for nested numeric vectors), so the
695            // wrapper serializes the result to a JSON string at the FFI boundary.
696            // Parse it on demand here so length/index assertions operate on the
697            // matrix structure rather than on the single string scalar.
698            "embeddings" => {
699                let parsed = format!(
700                    "(if (is.character({result_var}) && length({result_var}) == 1) jsonlite::fromJSON({result_var}, simplifyVector = FALSE) else {result_var})"
701                );
702                match assertion.assertion_type.as_str() {
703                    "count_equals" => {
704                        if let Some(val) = &assertion.value {
705                            let r_val = json_to_r(val, false);
706                            let _ = writeln!(out, "  expect_equal(length({parsed}), {r_val})");
707                        }
708                    }
709                    "count_min" => {
710                        if let Some(val) = &assertion.value {
711                            let r_val = json_to_r(val, false);
712                            let _ = writeln!(out, "  expect_gte(length({parsed}), {r_val})");
713                        }
714                    }
715                    "not_empty" => {
716                        let _ = writeln!(out, "  expect_gt(length({parsed}), 0)");
717                    }
718                    "is_empty" => {
719                        let _ = writeln!(out, "  expect_equal(length({parsed}), 0)");
720                    }
721                    _ => {
722                        let _ = writeln!(
723                            out,
724                            "  # skipped: unsupported assertion type on synthetic field 'embeddings'"
725                        );
726                    }
727                }
728                return;
729            }
730            "embedding_dimensions" => {
731                let expr = format!("(if (length({result_var}) == 0) 0L else length({result_var}[[1]]))");
732                match assertion.assertion_type.as_str() {
733                    "equals" => {
734                        if let Some(val) = &assertion.value {
735                            let r_val = json_to_r(val, false);
736                            let _ = writeln!(out, "  expect_equal({expr}, {r_val})");
737                        }
738                    }
739                    "greater_than" => {
740                        if let Some(val) = &assertion.value {
741                            let r_val = json_to_r(val, false);
742                            let _ = writeln!(out, "  expect_gt({expr}, {r_val})");
743                        }
744                    }
745                    _ => {
746                        let _ = writeln!(
747                            out,
748                            "  # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
749                        );
750                    }
751                }
752                return;
753            }
754            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
755                let pred = match f.as_str() {
756                    "embeddings_valid" => {
757                        format!("all(sapply({result_var}, function(e) length(e) > 0))")
758                    }
759                    "embeddings_finite" => {
760                        format!("all(sapply({result_var}, function(e) all(is.finite(e))))")
761                    }
762                    "embeddings_non_zero" => {
763                        format!("all(sapply({result_var}, function(e) any(e != 0.0)))")
764                    }
765                    "embeddings_normalized" => {
766                        format!("all(sapply({result_var}, function(e) abs(sum(e * e) - 1.0) < 1e-3))")
767                    }
768                    _ => unreachable!(),
769                };
770                match assertion.assertion_type.as_str() {
771                    "is_true" => {
772                        let _ = writeln!(out, "  expect_true({pred})");
773                    }
774                    "is_false" => {
775                        let _ = writeln!(out, "  expect_false({pred})");
776                    }
777                    _ => {
778                        let _ = writeln!(out, "  # skipped: unsupported assertion type on synthetic field '{f}'");
779                    }
780                }
781                return;
782            }
783            // ---- keywords / keywords_count ----
784            // R ExtractionResult does not expose extracted_keywords; skip.
785            "keywords" | "keywords_count" => {
786                let _ = writeln!(out, "  # skipped: field '{f}' not available on R ExtractionResult");
787                return;
788            }
789            _ => {}
790        }
791    }
792
793    // Skip assertions on fields that don't exist on the result type.
794    if let Some(f) = &assertion.field {
795        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
796            let _ = writeln!(out, "  # skipped: field '{f}' not available on result type");
797            return;
798        }
799    }
800
801    // When result_is_simple, skip assertions that reference non-content fields
802    // (e.g., metadata, document, structure) since the binding returns a plain value.
803    if result_is_simple {
804        if let Some(f) = &assertion.field {
805            let f_lower = f.to_lowercase();
806            if !f.is_empty()
807                && f_lower != "content"
808                && (f_lower.starts_with("metadata")
809                    || f_lower.starts_with("document")
810                    || f_lower.starts_with("structure"))
811            {
812                let _ = writeln!(
813                    out,
814                    "  # skipped: result_is_simple for field '{f}' not available on result type"
815                );
816                return;
817            }
818        }
819    }
820
821    let field_expr = if result_is_simple {
822        result_var.to_string()
823    } else {
824        match &assertion.field {
825            Some(f) if !f.is_empty() => field_resolver.accessor(f, "r", result_var),
826            _ => result_var.to_string(),
827        }
828    };
829
830    match assertion.assertion_type.as_str() {
831        "equals" => {
832            if let Some(expected) = &assertion.value {
833                let r_val = json_to_r(expected, false);
834                let _ = writeln!(out, "  expect_equal(trimws({field_expr}), {r_val})");
835            }
836        }
837        "contains" => {
838            if let Some(expected) = &assertion.value {
839                let r_val = json_to_r(expected, false);
840                let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}, fixed = TRUE))");
841            }
842        }
843        "contains_all" => {
844            if let Some(values) = &assertion.values {
845                for val in values {
846                    let r_val = json_to_r(val, false);
847                    let _ = writeln!(out, "  expect_true(any(grepl({r_val}, {field_expr}, fixed = TRUE)))");
848                }
849            }
850        }
851        "not_contains" => {
852            if let Some(expected) = &assertion.value {
853                let r_val = json_to_r(expected, false);
854                let _ = writeln!(out, "  expect_false(grepl({r_val}, {field_expr}, fixed = TRUE))");
855            }
856        }
857        "not_empty" => {
858            // Multi-element character vectors (e.g. `list_embedding_presets`)
859            // would otherwise evaluate `nchar(x) > 0` element-wise and fail
860            // `expect_true`'s scalar-logical contract. Reduce with `any()` so
861            // the predicate stays a single TRUE/FALSE regardless of length,
862            // and treat zero-length vectors as empty.
863            let _ = writeln!(
864                out,
865                "  expect_true(if (is.character({field_expr})) length({field_expr}) > 0 && any(nchar({field_expr}) > 0) else length({field_expr}) > 0)"
866            );
867        }
868        "is_empty" => {
869            // Rust `Option<String>::None` surfaces as `NA_character_` through
870            // extendr, and `Vec<...>` empties as a zero-length vector. Treat
871            // NULL, NA, "", and zero-length collections as "empty" so the same
872            // assertion works for scalar Option returns (`get_embedding_preset`)
873            // and collection returns alike.
874            let _ = writeln!(
875                out,
876                "  expect_true(is.null({field_expr}) || length({field_expr}) == 0 || (length({field_expr}) == 1 && (is.na({field_expr}) || identical({field_expr}, \"\"))))"
877            );
878        }
879        "contains_any" => {
880            if let Some(values) = &assertion.values {
881                let items: Vec<String> = values.iter().map(|v| json_to_r(v, false)).collect();
882                let vec_str = items.join(", ");
883                let _ = writeln!(
884                    out,
885                    "  expect_true(any(sapply(c({vec_str}), function(v) grepl(v, {field_expr}, fixed = TRUE))))"
886                );
887            }
888        }
889        "greater_than" => {
890            if let Some(val) = &assertion.value {
891                let r_val = json_to_r(val, false);
892                let _ = writeln!(out, "  expect_true({field_expr} > {r_val})");
893            }
894        }
895        "less_than" => {
896            if let Some(val) = &assertion.value {
897                let r_val = json_to_r(val, false);
898                let _ = writeln!(out, "  expect_true({field_expr} < {r_val})");
899            }
900        }
901        "greater_than_or_equal" => {
902            if let Some(val) = &assertion.value {
903                let r_val = json_to_r(val, false);
904                let _ = writeln!(out, "  expect_true({field_expr} >= {r_val})");
905            }
906        }
907        "less_than_or_equal" => {
908            if let Some(val) = &assertion.value {
909                let r_val = json_to_r(val, false);
910                let _ = writeln!(out, "  expect_true({field_expr} <= {r_val})");
911            }
912        }
913        "starts_with" => {
914            if let Some(expected) = &assertion.value {
915                let r_val = json_to_r(expected, false);
916                let _ = writeln!(out, "  expect_true(startsWith({field_expr}, {r_val}))");
917            }
918        }
919        "ends_with" => {
920            if let Some(expected) = &assertion.value {
921                let r_val = json_to_r(expected, false);
922                let _ = writeln!(out, "  expect_true(endsWith({field_expr}, {r_val}))");
923            }
924        }
925        "min_length" => {
926            if let Some(val) = &assertion.value {
927                if let Some(n) = val.as_u64() {
928                    let _ = writeln!(out, "  expect_true(nchar({field_expr}) >= {n})");
929                }
930            }
931        }
932        "max_length" => {
933            if let Some(val) = &assertion.value {
934                if let Some(n) = val.as_u64() {
935                    let _ = writeln!(out, "  expect_true(nchar({field_expr}) <= {n})");
936                }
937            }
938        }
939        "count_min" => {
940            if let Some(val) = &assertion.value {
941                if let Some(n) = val.as_u64() {
942                    let _ = writeln!(out, "  expect_true(length({field_expr}) >= {n})");
943                }
944            }
945        }
946        "count_equals" => {
947            if let Some(val) = &assertion.value {
948                if let Some(n) = val.as_u64() {
949                    let _ = writeln!(out, "  expect_equal(length({field_expr}), {n})");
950                }
951            }
952        }
953        "is_true" => {
954            let _ = writeln!(out, "  expect_true({field_expr})");
955        }
956        "is_false" => {
957            let _ = writeln!(out, "  expect_false({field_expr})");
958        }
959        "method_result" => {
960            if let Some(method_name) = &assertion.method {
961                let call_expr = build_r_method_call(result_var, method_name, assertion.args.as_ref());
962                let check = assertion.check.as_deref().unwrap_or("is_true");
963                match check {
964                    "equals" => {
965                        if let Some(val) = &assertion.value {
966                            if val.is_boolean() {
967                                if val.as_bool() == Some(true) {
968                                    let _ = writeln!(out, "  expect_true({call_expr})");
969                                } else {
970                                    let _ = writeln!(out, "  expect_false({call_expr})");
971                                }
972                            } else {
973                                let r_val = json_to_r(val, false);
974                                let _ = writeln!(out, "  expect_equal({call_expr}, {r_val})");
975                            }
976                        }
977                    }
978                    "is_true" => {
979                        let _ = writeln!(out, "  expect_true({call_expr})");
980                    }
981                    "is_false" => {
982                        let _ = writeln!(out, "  expect_false({call_expr})");
983                    }
984                    "greater_than_or_equal" => {
985                        if let Some(val) = &assertion.value {
986                            let r_val = json_to_r(val, false);
987                            let _ = writeln!(out, "  expect_true({call_expr} >= {r_val})");
988                        }
989                    }
990                    "count_min" => {
991                        if let Some(val) = &assertion.value {
992                            let n = val.as_u64().unwrap_or(0);
993                            let _ = writeln!(out, "  expect_true(length({call_expr}) >= {n})");
994                        }
995                    }
996                    "is_error" => {
997                        let _ = writeln!(out, "  expect_error({call_expr})");
998                    }
999                    "contains" => {
1000                        if let Some(val) = &assertion.value {
1001                            let r_val = json_to_r(val, false);
1002                            let _ = writeln!(out, "  expect_true(grepl({r_val}, {call_expr}, fixed = TRUE))");
1003                        }
1004                    }
1005                    other_check => {
1006                        panic!("R e2e generator: unsupported method_result check type: {other_check}");
1007                    }
1008                }
1009            } else {
1010                panic!("R e2e generator: method_result assertion missing 'method' field");
1011            }
1012        }
1013        "matches_regex" => {
1014            if let Some(expected) = &assertion.value {
1015                let r_val = json_to_r(expected, false);
1016                let _ = writeln!(out, "  expect_true(grepl({r_val}, {field_expr}))");
1017            }
1018        }
1019        "not_error" => {
1020            // The call itself stops the test on error; emit an explicit
1021            // `expect_true(TRUE)` so testthat doesn't report the test as
1022            // empty when this is the only assertion.
1023            let _ = writeln!(out, "  expect_true(TRUE)");
1024        }
1025        "error" => {
1026            // Handled at the test level.
1027        }
1028        other => {
1029            panic!("R e2e generator: unsupported assertion type: {other}");
1030        }
1031    }
1032}
1033
1034/// Convert a `serde_json::Value` to an R literal string.
1035///
1036/// # Arguments
1037///
1038/// * `value` - The JSON value to convert
1039///
1040/// Convert a PascalCase string to snake_case.
1041/// e.g. "DoubleEqual" → "double_equal", "Backticks" → "backticks"
1042fn pascal_to_snake_case(s: &str) -> String {
1043    let mut result = String::with_capacity(s.len() + 4);
1044    for (i, ch) in s.chars().enumerate() {
1045        if ch.is_uppercase() && i > 0 {
1046            result.push('_');
1047        }
1048        for lc in ch.to_lowercase() {
1049            result.push(lc);
1050        }
1051    }
1052    result
1053}
1054
1055/// Convert a JSON value to an R expression suitable for embedding inside a
1056/// `list(...)` that will be passed to `jsonlite::toJSON(..., auto_unbox = TRUE)`.
1057///
1058/// Differs from [`json_to_r`] in that any array-valued field is wrapped with
1059/// `I(...)` (jsonlite's `AsIs` marker) so it remains a JSON array after the
1060/// `auto_unbox` transform. Empty arrays become `I(list())` (→ `[]`) and
1061/// non-empty arrays become `I(c(...))` (→ `[..]`). Without this wrapping,
1062/// `Vec<String>` fields like `exclude_selectors` get unboxed to scalars and
1063/// serde deserialization on the Rust side fails with
1064/// `invalid type: string "foo", expected a sequence`.
1065fn json_to_r_preserve_arrays(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
1066    match value {
1067        serde_json::Value::Array(arr) => {
1068            if arr.is_empty() {
1069                "I(list())".to_string()
1070            } else {
1071                let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
1072                format!("I(c({}))", items.join(", "))
1073            }
1074        }
1075        serde_json::Value::Object(map) => {
1076            let entries: Vec<String> = map
1077                .iter()
1078                .map(|(k, v)| {
1079                    format!(
1080                        "\"{}\" = {}",
1081                        escape_r(k),
1082                        json_to_r_preserve_arrays(v, lowercase_enum_values)
1083                    )
1084                })
1085                .collect();
1086            format!("list({})", entries.join(", "))
1087        }
1088        _ => json_to_r(value, lowercase_enum_values),
1089    }
1090}
1091
1092/// * `lowercase_enum_values` - If true, convert PascalCase strings to snake_case (for enum values).
1093///   If false, preserve original case (for assertion expected values).
1094fn json_to_r(value: &serde_json::Value, lowercase_enum_values: bool) -> String {
1095    match value {
1096        serde_json::Value::String(s) => {
1097            // Convert PascalCase enum values to snake_case only if requested.
1098            // e.g. "Backticks" → "backticks", "DoubleEqual" → "double_equal"
1099            let normalized = if lowercase_enum_values && s.chars().next().is_some_and(|c| c.is_uppercase()) {
1100                pascal_to_snake_case(s)
1101            } else {
1102                s.clone()
1103            };
1104            format!("\"{}\"", escape_r(&normalized))
1105        }
1106        serde_json::Value::Bool(true) => "TRUE".to_string(),
1107        serde_json::Value::Bool(false) => "FALSE".to_string(),
1108        serde_json::Value::Number(n) => n.to_string(),
1109        serde_json::Value::Null => "NULL".to_string(),
1110        serde_json::Value::Array(arr) => {
1111            let items: Vec<String> = arr.iter().map(|v| json_to_r(v, lowercase_enum_values)).collect();
1112            format!("c({})", items.join(", "))
1113        }
1114        serde_json::Value::Object(map) => {
1115            let entries: Vec<String> = map
1116                .iter()
1117                .map(|(k, v)| format!("\"{}\" = {}", escape_r(k), json_to_r(v, lowercase_enum_values)))
1118                .collect();
1119            format!("list({})", entries.join(", "))
1120        }
1121    }
1122}
1123
1124/// Build an R visitor list and add setup line.
1125fn build_r_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
1126    use std::fmt::Write as FmtWrite;
1127    // Collect each callback as a separate string, then join with ",\n" to avoid
1128    // trailing commas — R's list() does not accept a trailing comma.
1129    let methods: Vec<String> = visitor_spec
1130        .callbacks
1131        .iter()
1132        .map(|(method_name, action)| {
1133            let mut buf = String::new();
1134            emit_r_visitor_method(&mut buf, method_name, action);
1135            // strip the trailing ",\n" added by emit_r_visitor_method
1136            buf.trim_end_matches(['\n', ',']).to_string()
1137        })
1138        .collect();
1139    let mut visitor_obj = String::new();
1140    let _ = writeln!(visitor_obj, "list(");
1141    let _ = write!(visitor_obj, "{}", methods.join(",\n"));
1142    let _ = writeln!(visitor_obj);
1143    let _ = writeln!(visitor_obj, "  )");
1144
1145    setup_lines.push(format!("visitor <- {visitor_obj}"));
1146}
1147
1148/// Build an R call expression for a `method_result` assertion.
1149/// Maps method names to the appropriate R function or method calls.
1150fn build_r_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1151    match method_name {
1152        "root_child_count" => format!("{result_var}$root_child_count()"),
1153        "root_node_type" => format!("{result_var}$root_node_type()"),
1154        "named_children_count" => format!("{result_var}$named_children_count()"),
1155        "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
1156        "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
1157        "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
1158        "contains_node_type" => {
1159            let node_type = args
1160                .and_then(|a| a.get("node_type"))
1161                .and_then(|v| v.as_str())
1162                .unwrap_or("");
1163            format!("tree_contains_node_type({result_var}, \"{node_type}\")")
1164        }
1165        "find_nodes_by_type" => {
1166            let node_type = args
1167                .and_then(|a| a.get("node_type"))
1168                .and_then(|v| v.as_str())
1169                .unwrap_or("");
1170            format!("find_nodes_by_type({result_var}, \"{node_type}\")")
1171        }
1172        "run_query" => {
1173            let query_source = args
1174                .and_then(|a| a.get("query_source"))
1175                .and_then(|v| v.as_str())
1176                .unwrap_or("");
1177            let language = args
1178                .and_then(|a| a.get("language"))
1179                .and_then(|v| v.as_str())
1180                .unwrap_or("");
1181            format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1182        }
1183        _ => {
1184            if let Some(args_val) = args {
1185                let arg_str = args_val
1186                    .as_object()
1187                    .map(|obj| {
1188                        obj.iter()
1189                            .map(|(k, v)| {
1190                                let r_val = json_to_r(v, false);
1191                                format!("{k} = {r_val}")
1192                            })
1193                            .collect::<Vec<_>>()
1194                            .join(", ")
1195                    })
1196                    .unwrap_or_default();
1197                format!("{result_var}${method_name}({arg_str})")
1198            } else {
1199                format!("{result_var}${method_name}()")
1200            }
1201        }
1202    }
1203}
1204
1205/// Emit an R visitor method for a callback action.
1206fn emit_r_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1207    use std::fmt::Write as FmtWrite;
1208
1209    // R uses visit_ prefix (matches binding signature)
1210    let params = match method_name {
1211        "visit_link" => "ctx, href, text, title",
1212        "visit_image" => "ctx, src, alt, title",
1213        "visit_heading" => "ctx, level, text, id",
1214        "visit_code_block" => "ctx, lang, code",
1215        "visit_code_inline"
1216        | "visit_strong"
1217        | "visit_emphasis"
1218        | "visit_strikethrough"
1219        | "visit_underline"
1220        | "visit_subscript"
1221        | "visit_superscript"
1222        | "visit_mark"
1223        | "visit_button"
1224        | "visit_summary"
1225        | "visit_figcaption"
1226        | "visit_definition_term"
1227        | "visit_definition_description" => "ctx, text",
1228        "visit_text" => "ctx, text",
1229        "visit_list_item" => "ctx, ordered, marker, text",
1230        "visit_blockquote" => "ctx, content, depth",
1231        "visit_table_row" => "ctx, cells, is_header",
1232        "visit_custom_element" => "ctx, tag_name, html",
1233        "visit_form" => "ctx, action_url, method",
1234        "visit_input" => "ctx, input_type, name, value",
1235        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1236        "visit_details" => "ctx, open",
1237        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1238        "visit_list_start" => "ctx, ordered",
1239        "visit_list_end" => "ctx, ordered, output",
1240        _ => "ctx",
1241    };
1242
1243    let _ = writeln!(out, "    {method_name} = function({params}) {{");
1244    match action {
1245        CallbackAction::Skip => {
1246            let _ = writeln!(out, "      \"skip\"");
1247        }
1248        CallbackAction::Continue => {
1249            let _ = writeln!(out, "      \"continue\"");
1250        }
1251        CallbackAction::PreserveHtml => {
1252            let _ = writeln!(out, "      \"preserve_html\"");
1253        }
1254        CallbackAction::Custom { output } => {
1255            let escaped = escape_r(output);
1256            let _ = writeln!(out, "      list(custom = \"{escaped}\")");
1257        }
1258        CallbackAction::CustomTemplate { template, return_form } => {
1259            let r_expr = r_template_to_paste0(template);
1260            match return_form {
1261                TemplateReturnForm::BareString => {
1262                    let _ = writeln!(out, "      {r_expr}");
1263                }
1264                TemplateReturnForm::Dict => {
1265                    let _ = writeln!(out, "      list(custom = {r_expr})");
1266                }
1267            }
1268        }
1269    }
1270    let _ = writeln!(out, "    }},");
1271}