use crate::codegen::builder::RustFileBuilder;
use crate::codegen::doc_emission::{parse_arguments_bullets, parse_rustdoc_sections};
use crate::codegen::generators::trait_bridge::find_bridge_field;
use crate::codegen::generators::{self, AsyncPattern, RustBindingConfig};
use crate::codegen::type_mapper::TypeMapper;
use crate::core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile};
use crate::core::config::{Language, ResolvedCrateConfig, resolve_output_dir};
use crate::core::hash::{self, CommentStyle};
use crate::core::ir::{ApiSurface, EnumDef, FunctionDef, ParamDef, TypeDef, TypeRef};
use ahash::AHashSet;
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
pub struct ExtendrBackend;
impl ExtendrBackend {
fn binding_config<'a>(core_import: &'a str, lossy_skip_types: &'a [String]) -> RustBindingConfig<'a> {
RustBindingConfig {
struct_attrs: &[],
field_attrs: &[],
struct_derives: &["Clone"],
method_block_attr: Some("extendr"),
constructor_attr: "",
static_attr: None,
function_attr: "#[extendr]",
enum_attrs: &[],
enum_derives: &["Clone", "PartialEq"],
needs_signature: false,
signature_prefix: "",
signature_suffix: "",
core_import,
async_pattern: AsyncPattern::TokioBlockOn,
has_serde: true,
type_name_prefix: "",
option_duration_on_defaults: false,
opaque_type_names: &[],
skip_impl_constructor: true,
cast_uints_to_i32: true,
cast_large_ints_to_f64: true,
named_non_opaque_params_by_ref: true,
lossy_skip_types,
serializable_opaque_type_names: &[],
never_skip_cfg_field_names: &[],
emit_delegating_default_impl: false,
}
}
}
impl TypeMapper for ExtendrBackend {
fn primitive(&self, prim: &crate::core::ir::PrimitiveType) -> Cow<'static, str> {
use crate::core::ir::PrimitiveType;
match prim {
PrimitiveType::Bool => Cow::Borrowed("bool"),
PrimitiveType::U8
| PrimitiveType::U16
| PrimitiveType::U32
| PrimitiveType::I8
| PrimitiveType::I16
| PrimitiveType::I32 => Cow::Borrowed("i32"),
PrimitiveType::U64 | PrimitiveType::I64 | PrimitiveType::Usize | PrimitiveType::Isize => {
Cow::Borrowed("f64")
}
PrimitiveType::F32 | PrimitiveType::F64 => Cow::Borrowed("f64"),
}
}
fn json(&self) -> Cow<'static, str> {
Cow::Borrowed("String")
}
fn error_wrapper(&self) -> &str {
"Result"
}
}
impl Backend for ExtendrBackend {
fn name(&self) -> &str {
"extendr"
}
fn language(&self) -> Language {
Language::R
}
fn capabilities(&self) -> Capabilities {
Capabilities {
supports_async: true,
supports_classes: true,
supports_enums: true,
supports_option: true,
supports_result: true,
..Capabilities::default()
}
}
fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let core_import = config.core_import_name();
let flat_data_enum_names_vec: Vec<String> = api
.enums
.iter()
.filter(|e| is_flat_data_enum(e))
.map(|e| e.name.clone())
.collect();
let json_passthrough_enum_names_vec: Vec<String> = api
.enums
.iter()
.filter(|e| is_json_passthrough_data_enum(e))
.map(|e| e.name.clone())
.collect();
let cfg = Self::binding_config(&core_import, &flat_data_enum_names_vec);
let adapter_bodies = crate::adapters::build_adapter_bodies(config, Language::R)?;
let mut builder = RustFileBuilder::new().with_generated_header();
builder.add_inner_attribute("allow(dead_code, unused_imports, unused_variables)");
builder.add_inner_attribute("allow(clippy::too_many_arguments, clippy::let_unit_value, clippy::needless_borrow, clippy::map_identity, clippy::just_underscores_and_digits, clippy::unused_unit, clippy::unnecessary_cast, clippy::unwrap_or_default, clippy::derivable_impls, clippy::needless_borrows_for_generic_args, clippy::unnecessary_fallible_conversions)");
builder.add_import("extendr_api::prelude::*");
builder.add_import("std::collections::HashMap");
builder.add_import("extendr_api::Result");
for trait_path in generators::collect_trait_imports(api) {
builder.add_import(&trait_path);
}
let custom_mods = config.custom_modules.for_language(Language::R);
for module in custom_mods {
builder.add_item(&format!("pub mod {module};"));
}
let opaque_types: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque)
.map(|t| t.name.clone())
.collect();
let arc_incompatible_opaque: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque && t.cfg.is_some())
.map(|t| t.name.clone())
.collect();
let arc_incompatible_opaque_vec: Vec<String> = arc_incompatible_opaque.iter().cloned().collect();
let mutex_types: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque && generators::type_needs_mutex(t))
.map(|t| t.name.clone())
.collect();
let enum_names: ahash::AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
let references_arc_incompatible = |ty: &crate::core::ir::TypeRef| -> bool {
arc_incompatible_opaque_vec.iter().any(|n| {
matches!(ty, crate::core::ir::TypeRef::Named(name) if name == n)
|| matches!(ty, crate::core::ir::TypeRef::Optional(inner) if matches!(inner.as_ref(), crate::core::ir::TypeRef::Named(name) if name == n))
})
};
let method_references_arc_incompatible = |m: &crate::core::ir::MethodDef| -> bool {
references_arc_incompatible(&m.return_type) || m.params.iter().any(|p| references_arc_incompatible(&p.ty))
};
let references_enum = |ty: &crate::core::ir::TypeRef| -> bool {
match ty {
crate::core::ir::TypeRef::Named(n) => enum_names.contains(n.as_str()),
crate::core::ir::TypeRef::Optional(inner) => {
matches!(inner.as_ref(), crate::core::ir::TypeRef::Named(n) if enum_names.contains(n.as_str()))
}
_ => false,
}
};
let param_is_owned_struct = |ty: &crate::core::ir::TypeRef| -> bool {
let is_non_opaque_struct =
|n: &str| !opaque_types.contains(n) && !enum_names.contains(n) && !arc_incompatible_opaque.contains(n);
match ty {
crate::core::ir::TypeRef::Named(n) => is_non_opaque_struct(n),
crate::core::ir::TypeRef::Optional(inner) => {
matches!(inner.as_ref(), crate::core::ir::TypeRef::Named(n) if is_non_opaque_struct(n))
}
_ => false,
}
};
let method_references_enum = |m: &crate::core::ir::MethodDef| -> bool {
references_enum(&m.return_type)
|| m.params
.iter()
.any(|p| references_enum(&p.ty) || param_is_owned_struct(&p.ty))
};
let is_extendr_native_incompatible = |ty: &crate::core::ir::TypeRef| -> bool {
let is_vec_element_incompatible =
|n: &str| !opaque_types.contains(n) && !enum_names.contains(n) && !arc_incompatible_opaque.contains(n);
match ty {
crate::core::ir::TypeRef::Vec(inner) => {
match inner.as_ref() {
crate::core::ir::TypeRef::Named(n) if is_vec_element_incompatible(n) => true,
crate::core::ir::TypeRef::Vec(_) => true,
_ => false,
}
}
crate::core::ir::TypeRef::Optional(inner) => {
if let crate::core::ir::TypeRef::Vec(inner2) = inner.as_ref() {
match inner2.as_ref() {
crate::core::ir::TypeRef::Named(n) if is_vec_element_incompatible(n) => true,
crate::core::ir::TypeRef::Vec(_) => true,
_ => false,
}
} else {
false
}
}
_ => false,
}
};
let extendr_incompatible_types: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| !t.is_opaque && !t.is_trait)
.filter(|t| t.fields.iter().any(|f| is_extendr_native_incompatible(&f.ty)))
.map(|t| t.name.clone())
.collect();
let input_type_names: ahash::AHashSet<String> = {
fn collect_named(ty: &crate::core::ir::TypeRef, set: &mut ahash::AHashSet<String>) {
match ty {
crate::core::ir::TypeRef::Named(n) => {
set.insert(n.clone());
}
crate::core::ir::TypeRef::Optional(inner) => collect_named(inner, set),
crate::core::ir::TypeRef::Vec(inner) => collect_named(inner, set),
_ => {}
}
}
let mut set = ahash::AHashSet::new();
for func in &api.functions {
for p in &func.params {
collect_named(&p.ty, &mut set);
}
}
for typ in &api.types {
for m in &typ.methods {
for p in &m.params {
collect_named(&p.ty, &mut set);
}
}
}
set
};
let has_arc_compatible = opaque_types.iter().any(|n| !arc_incompatible_opaque.contains(n));
if has_arc_compatible {
builder.add_import("std::sync::Arc");
}
for typ in api.types.iter().filter(|typ| !typ.is_trait) {
if typ.is_opaque {
if arc_incompatible_opaque.contains(&typ.name) {
continue;
}
let opaque_struct = generators::gen_opaque_struct(typ, &cfg);
builder.add_item(&format!("#[extendr]\n{opaque_struct}"));
let has_excluded_opaque_methods = typ
.methods
.iter()
.any(|m| method_references_arc_incompatible(m) || method_references_enum(m));
let opaque_impl_typ: std::borrow::Cow<crate::core::ir::TypeDef> = if has_excluded_opaque_methods {
let filtered = crate::core::ir::TypeDef {
methods: typ
.methods
.iter()
.filter(|m| !method_references_arc_incompatible(m) && !method_references_enum(m))
.cloned()
.collect(),
..typ.clone()
};
std::borrow::Cow::Owned(filtered)
} else {
std::borrow::Cow::Borrowed(typ)
};
let impl_block = generators::gen_opaque_impl_block(
&opaque_impl_typ,
self,
&cfg,
&opaque_types,
&mutex_types,
&adapter_bodies,
);
if !impl_block.is_empty() {
builder.add_item(&impl_block);
} else {
builder.add_item(&format!("#[extendr]\nimpl {} {{}}", typ.name));
}
} else {
let has_excluded_fields = typ.fields.iter().any(|f| references_arc_incompatible(&f.ty));
let has_excluded_methods = typ
.methods
.iter()
.any(|m| method_references_arc_incompatible(m) || method_references_enum(m));
let struct_typ: std::borrow::Cow<crate::core::ir::TypeDef> =
if has_excluded_fields || has_excluded_methods {
let filtered = crate::core::ir::TypeDef {
fields: typ
.fields
.iter()
.filter(|f| !references_arc_incompatible(&f.ty))
.cloned()
.collect(),
methods: typ
.methods
.iter()
.filter(|m| !method_references_arc_incompatible(m) && !method_references_enum(m))
.cloned()
.collect(),
..typ.clone()
};
std::borrow::Cow::Owned(filtered)
} else {
std::borrow::Cow::Borrowed(typ)
};
if extendr_incompatible_types.contains(&struct_typ.name) {
builder.add_item(&generators::gen_struct(&struct_typ, self, &cfg));
} else {
let struct_item = generators::gen_struct(&struct_typ, self, &cfg);
builder.add_item(&format!("#[extendr]\n{struct_item}"));
let from_json_method = if struct_typ.has_default
&& !struct_typ.fields.is_empty()
&& input_type_names.contains(&struct_typ.name)
{
let type_name = &struct_typ.name;
let core_path = struct_typ.rust_path.replace('-', "_");
format!(
" pub fn from_json(json: String) -> extendr_api::Result<{type_name}> {{\n \
let core: {core_path} = serde_json::from_str(&json)\n \
.map_err(|e| extendr_api::Error::Other(e.to_string()))?;\n \
Ok(core.into())\n \
}}\n"
)
} else {
String::new()
};
let impl_block =
generators::gen_impl_block(&struct_typ, self, &cfg, &adapter_bodies, &opaque_types);
if !impl_block.is_empty() {
let final_impl = if from_json_method.is_empty() {
impl_block
} else if let Some(pos) = impl_block.rfind('}') {
format!("{}{}{}", &impl_block[..pos], &from_json_method, &impl_block[pos..])
} else {
impl_block
};
builder.add_item(&final_impl);
} else {
let empty_or_from_json = if from_json_method.is_empty() {
format!("#[extendr]\nimpl {} {{}}", struct_typ.name)
} else {
format!("#[extendr]\nimpl {} {{\n{}}}", struct_typ.name, from_json_method)
};
builder.add_item(&empty_or_from_json);
}
if struct_typ.has_default && !struct_typ.fields.is_empty() {
let map_fn = |ty: &crate::core::ir::TypeRef| self.map_type(ty);
let config_fn = crate::codegen::config_gen::gen_extendr_kwargs_constructor(
&struct_typ,
&map_fn,
&enum_names,
);
builder.add_item(&config_fn);
}
}
}
}
for e in &api.enums {
if is_flat_data_enum(e) {
let flat_struct = gen_extendr_flat_data_enum_struct(e, self, &cfg);
builder.add_item(&format!("#[extendr]\n{flat_struct}"));
builder.add_item(&format!("#[extendr]\nimpl {} {{}}", e.name));
} else if is_json_passthrough_data_enum(e) {
builder.add_item(&gen_extendr_json_passthrough_enum_struct(e, &core_import));
} else {
builder.add_item(&generators::gen_enum(e, &cfg));
}
}
let binding_to_core = crate::codegen::conversions::convertible_types(api);
let core_to_binding = crate::codegen::conversions::core_to_binding_convertible_types(api);
let input_types = crate::codegen::conversions::input_type_names(api);
let non_round_trip_flat_enums: Vec<String> = api
.enums
.iter()
.filter(|e| is_flat_data_enum(e) && !can_flat_data_enum_round_trip(e))
.map(|e| e.name.clone())
.collect();
let extendr_conversion_cfg = crate::codegen::conversions::ConversionConfig {
cast_uints_to_i32: true,
cast_large_ints_to_f64: true,
exclude_types: &arc_incompatible_opaque_vec,
from_binding_skip_types: &non_round_trip_flat_enums,
strip_cfg_fields_from_binding_struct: true,
..crate::codegen::conversions::ConversionConfig::default()
};
for typ in api.types.iter().filter(|typ| !typ.is_trait) {
if input_types.contains(&typ.name)
&& crate::codegen::conversions::can_generate_conversion(typ, &binding_to_core)
{
builder.add_item(&crate::codegen::conversions::gen_from_binding_to_core_cfg(
typ,
&core_import,
&extendr_conversion_cfg,
));
}
if crate::codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
builder.add_item(&crate::codegen::conversions::gen_from_core_to_binding_cfg(
typ,
&core_import,
&opaque_types,
&extendr_conversion_cfg,
));
}
}
for e in &api.enums {
if is_flat_data_enum(e) {
if crate::codegen::conversions::can_generate_enum_conversion_from_core(e) {
builder.add_item(&gen_extendr_flat_data_enum_from_core(e, &core_import));
if can_flat_data_enum_round_trip(e) {
builder.add_item(&gen_extendr_flat_data_enum_to_core(e, &core_import));
}
}
} else if is_json_passthrough_data_enum(e) {
continue;
} else {
if input_types.contains(&e.name) && crate::codegen::conversions::can_generate_enum_conversion(e) {
builder.add_item(&crate::codegen::conversions::gen_enum_from_binding_to_core(
e,
&core_import,
));
}
if crate::codegen::conversions::can_generate_enum_conversion_from_core(e) {
builder.add_item(&crate::codegen::conversions::gen_enum_from_core_to_binding(
e,
&core_import,
));
}
}
}
let active_bridges: Vec<_> = config
.trait_bridges
.iter()
.filter(|b| !b.exclude_languages.iter().any(|l| l == "r" || l == "extendr"))
.cloned()
.collect();
let bridge_fn_names = collect_trait_bridge_fn_names(config);
let r_exclude_functions: ahash::AHashSet<String> = config
.r
.as_ref()
.map(|c| c.exclude_functions.iter().cloned().collect())
.unwrap_or_default();
for func in &api.functions {
if bridge_fn_names.contains(&func.name) {
continue;
}
if r_exclude_functions.contains(&func.name) {
continue;
}
let bridge_param = crate::backends::extendr::trait_bridge::find_bridge_param(func, &active_bridges);
let bridge_field = find_bridge_field(func, &api.types, &active_bridges);
if let Some((param_idx, bridge_cfg)) = bridge_param {
builder.add_item(&crate::backends::extendr::trait_bridge::gen_bridge_function(
func,
param_idx,
bridge_cfg,
self,
&opaque_types,
&core_import,
));
} else if let Some(bm) = bridge_field {
builder.add_item(&gen_extendr_bridge_field_function(func, &bm, &core_import));
} else {
let func_return_needs_json = return_type_needs_json(
&func.return_type,
&extendr_incompatible_types,
&enum_names,
&opaque_types,
);
let func_params_need_json = func.params.iter().any(|p| is_extendr_native_incompatible(&p.ty));
if func_return_needs_json || func_params_need_json {
builder.add_item(&gen_extendr_json_bridged_function(
func,
self,
&core_import,
&opaque_types,
&cfg,
&extendr_incompatible_types,
));
} else {
builder.add_item(&generators::gen_function(
func,
self,
&cfg,
&adapter_bodies,
&opaque_types,
));
}
}
}
let mut emitted_send_robj_helper = false;
for bridge_cfg in &config.trait_bridges {
if bridge_cfg.exclude_languages.iter().any(|l| l == "r" || l == "extendr") {
continue;
}
if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
if !emitted_send_robj_helper {
builder.add_item(crate::backends::extendr::trait_bridge::gen_send_robj_helper());
emitted_send_robj_helper = true;
}
let bridge = crate::backends::extendr::trait_bridge::gen_trait_bridge(
trait_type,
bridge_cfg,
&core_import,
&config.error_type_name(),
&config.error_constructor_expr(),
api,
);
for imp in &bridge.imports {
builder.add_import(imp);
}
builder.add_item(&bridge.code);
}
}
let module_name = config.r_package_name().replace('-', "_");
let module_items = format!(
"extendr_module! {{\n mod {module};\n{types}{flat_enums}{json_enums}{funcs}}}\n",
module = module_name,
types = api
.types
.iter()
.filter(|t| {
!t.is_trait
&& !arc_incompatible_opaque.contains(&t.name)
&& !extendr_incompatible_types.contains(&t.name)
})
.map(|t| format!(" impl {};\n", t.name))
.collect::<String>(),
flat_enums = flat_data_enum_names_vec
.iter()
.map(|n| format!(" impl {n};\n"))
.collect::<String>(),
json_enums = json_passthrough_enum_names_vec
.iter()
.map(|n| format!(" impl {n};\n"))
.collect::<String>(),
funcs = api
.functions
.iter()
.filter(|f| !bridge_fn_names.contains(&f.name))
.map(|f| format!(" fn {};\n", f.name))
.collect::<String>()
+ &collect_trait_bridge_functions(config)
.iter()
.map(|tb| format!(" fn {};\n", tb.name))
.collect::<String>(),
);
builder.add_item(&module_items);
let output_path = resolve_output_dir(config.output_paths.get("r"), &config.name, "packages/r/src/rust/src");
Ok(vec![GeneratedFile {
path: PathBuf::from(&output_path).join("lib.rs"),
content: builder.build(),
generated_header: false,
}])
}
fn generate_public_api(
&self,
api: &ApiSurface,
config: &ResolvedCrateConfig,
) -> anyhow::Result<Vec<GeneratedFile>> {
let package_name = config.r_package_name();
let r_wrapper_dir = if let Some(rust_out) = config.output_paths.get("r") {
let rust_str = rust_out.to_string_lossy();
let suffixes = ["src/rust/src/", "src/rust/src"];
let base = suffixes
.iter()
.find_map(|s| rust_str.strip_suffix(s))
.unwrap_or_else(|| rust_str.as_ref());
format!("{base}R/")
} else {
"packages/r/R/".to_string()
};
let r_pkg_dir = r_wrapper_dir.trim_end_matches("R/").trim_end_matches("R");
let mut files = Vec::new();
let mut pkg_content = hash::header(CommentStyle::Hash);
pkg_content.push('\n');
pkg_content.push_str(&crate::backends::extendr::template_env::render(
"r_use_dyn_lib.jinja",
minijinja::context! { package_name => package_name },
));
pkg_content.push_str("NULL\n");
files.push(GeneratedFile {
path: PathBuf::from(&r_wrapper_dir).join(format!("{package_name}.R")),
content: pkg_content,
generated_header: false,
});
let input_type_names = crate::codegen::conversions::input_type_names(api);
let trait_bridge_fns = collect_trait_bridge_functions(config);
let r_exclude_functions: ahash::AHashSet<String> = config
.r
.as_ref()
.map(|c| c.exclude_functions.iter().cloned().collect())
.unwrap_or_default();
let wrappers_content = gen_extendr_wrappers_r(
api,
&package_name,
&input_type_names,
&trait_bridge_fns,
&r_exclude_functions,
);
files.push(GeneratedFile {
path: PathBuf::from(&r_wrapper_dir).join("extendr-wrappers.R"),
content: wrappers_content,
generated_header: false,
});
let namespace_content = gen_namespace(api, &package_name, &trait_bridge_fns, &r_exclude_functions);
files.push(GeneratedFile {
path: PathBuf::from(r_pkg_dir).join("NAMESPACE"),
content: namespace_content,
generated_header: false,
});
if let Some(opts_type) = api.types.iter().find(|t| t.name == "ConversionOptions" && !t.is_trait) {
let options_r = gen_conversion_options_r(opts_type);
files.push(GeneratedFile {
path: PathBuf::from(&r_wrapper_dir).join("options.R"),
content: options_r,
generated_header: true,
});
}
Ok(files)
}
fn build_config(&self) -> Option<BuildConfig> {
Some(BuildConfig {
tool: "cargo",
crate_suffix: "-extendr",
build_dep: BuildDependency::None,
post_build: vec![],
})
}
}
fn gen_conversion_options_r(opts_type: &TypeDef) -> String {
use crate::core::ir::PrimitiveType;
let params: Vec<String> = opts_type
.fields
.iter()
.map(|f| format!("{} = NULL", f.name.trim_start_matches('_')))
.collect();
let fields: Vec<minijinja::Value> = opts_type
.fields
.iter()
.map(|field| {
let rname = field.name.trim_start_matches('_');
let doc_text = if field.doc.is_empty() {
rname.to_string()
} else {
let first = field.doc.lines().next().unwrap_or(rname);
first.trim_end_matches('.').to_string()
};
let needs_int = matches!(
&field.ty,
TypeRef::Primitive(PrimitiveType::U8)
| TypeRef::Primitive(PrimitiveType::U16)
| TypeRef::Primitive(PrimitiveType::U32)
| TypeRef::Primitive(PrimitiveType::U64)
| TypeRef::Primitive(PrimitiveType::I8)
| TypeRef::Primitive(PrimitiveType::I16)
| TypeRef::Primitive(PrimitiveType::I32)
| TypeRef::Primitive(PrimitiveType::I64)
| TypeRef::Primitive(PrimitiveType::Usize)
);
let assign_val = if needs_int {
format!("as.integer({rname})")
} else {
rname.to_string()
};
minijinja::context! {
rname => rname,
doc => doc_text,
cfg => field.cfg.is_some(),
assign_val => assign_val,
}
})
.collect();
crate::backends::extendr::template_env::render(
"conversion_options.jinja",
minijinja::context! {
params => params,
fields => fields,
},
)
}
fn is_flat_data_enum(e: &crate::core::ir::EnumDef) -> bool {
let has_data = e.variants.iter().any(|v| !v.fields.is_empty());
has_data
&& e.variants
.iter()
.filter(|v| !v.fields.is_empty())
.all(|v| v.fields.len() == 1)
}
fn can_flat_data_enum_round_trip(e: &crate::core::ir::EnumDef) -> bool {
e.variants.iter().all(|v| {
if v.fields.is_empty() {
return true; }
if v.is_tuple && v.fields.len() == 1 {
let ty = &v.fields[0].ty;
matches!(ty, crate::core::ir::TypeRef::String)
|| matches!(ty, crate::core::ir::TypeRef::Optional(inner) if matches!(inner.as_ref(), crate::core::ir::TypeRef::String))
} else {
false
}
})
}
fn is_json_passthrough_data_enum(e: &crate::core::ir::EnumDef) -> bool {
if is_flat_data_enum(e) {
return false;
}
if e.serde_tag.is_none() {
return false;
}
e.variants.iter().any(|v| !v.fields.is_empty())
}
fn gen_extendr_json_passthrough_enum_struct(enum_def: &crate::core::ir::EnumDef, core_import: &str) -> String {
let name = &enum_def.name;
let core_path = format!("{core_import}::{name}");
format!(
r#"#[extendr]
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(from = "{core_path}", into = "{core_path}")]
pub struct {name} {{
/// Serde-JSON encoding of the underlying core enum value. Preserves the
/// tagged-variant payload across the FFI boundary so round trips don't drop
/// inner field data. The field is private-by-convention (double-underscore
/// prefix) and not surfaced in R; construction goes through `from_json`.
#[serde(skip)]
pub __inner: String,
}}
impl From<{core_path}> for {name} {{
fn from(value: {core_path}) -> Self {{
Self {{
__inner: serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
}}
}}
}}
impl From<{name}> for {core_path} {{
fn from(value: {name}) -> Self {{
if value.__inner.is_empty() {{
return <{core_path}>::default();
}}
serde_json::from_str(&value.__inner).unwrap_or_default()
}}
}}
#[extendr]
impl {name} {{
#[allow(clippy::should_implement_trait)]
pub fn default() -> {name} {{
<{core_path}>::default().into()
}}
pub fn from_json(json: String) -> extendr_api::Result<{name}> {{
let core: {core_path} =
serde_json::from_str(&json).map_err(|e| extendr_api::Error::Other(e.to_string()))?;
Ok(core.into())
}}
}}
"#
)
}
fn gen_extendr_bridge_field_function(
func: &FunctionDef,
bridge_match: &crate::codegen::generators::trait_bridge::BridgeFieldMatch<'_>,
core_import: &str,
) -> String {
let func_name = &func.name;
let options_param = &bridge_match.param_name;
let field_name = &bridge_match.field_name;
let mut param_parts = Vec::new();
for param in &func.params {
if param.name == *options_param {
param_parts.push(format!("{}: Robj", param.name));
} else {
match ¶m.ty {
TypeRef::String => param_parts.push(format!("{}: String", param.name)),
_ => param_parts.push(format!("{}: Robj", param.name)),
}
}
}
let params_str = param_parts.join(", ");
let return_type = "Result<Robj>";
let mut body = String::with_capacity(1024);
body.push_str(" use std::sync::Arc;\n");
body.push_str(" use std::sync::Mutex;\n");
body.push_str(&format!(
" let {field_name}_robj: Option<Robj> = {options_param}.clone().as_list().and_then(|list| {{\n"
));
body.push_str(&format!(
" list.iter().find(|(k, _)| *k == \"{field_name}\").map(|(_, v)| v)\n"
));
body.push_str(" }).filter(|v| !v.is_null() && !v.is_na());\n");
body.push_str(&format!(
" let {field_name}_handle: Option<{core_import}::visitor::VisitorHandle> = {field_name}_robj\n"
));
body.push_str(&format!(
" .map(|v| Arc::new(Mutex::new(RHtmlVisitorBridge::new(v))) as {core_import}::visitor::VisitorHandle);\n"
));
body.push_str(&format!(
" let mut opts = crate::options::decode_options({options_param})\n"
));
body.push_str(" .map_err(|e| extendr_api::Error::Other(e))?;\n");
body.push_str(&format!(" opts.{field_name} = {field_name}_handle;\n"));
let mut call_args = Vec::new();
for param in &func.params {
if param.name == *options_param {
call_args.push("Some(opts)".to_string());
} else {
call_args.push(format!("&{}", param.name));
}
}
body.push_str(&format!(" {core_import}::{func_name}({})\n", call_args.join(", ")));
body.push_str(" .map(crate::types::conversion_result_to_robj)\n");
body.push_str(" .map_err(|e| extendr_api::Error::Other(e.to_string()))\n");
format!("#[extendr]\npub fn {func_name}({params_str}) -> {return_type} {{\n{body}}}\n")
}
fn gen_extendr_flat_data_enum_struct(
enum_def: &crate::core::ir::EnumDef,
mapper: &dyn crate::codegen::type_mapper::TypeMapper,
cfg: &crate::codegen::generators::RustBindingConfig,
) -> String {
let name = &enum_def.name;
let discriminator = enum_def.serde_tag.as_deref().unwrap_or("format_type");
let mut out = String::with_capacity(1024);
let mut derives: Vec<&str> = cfg.struct_derives.to_vec();
derives.push("Default");
derives.push("serde::Serialize");
derives.push("serde::Deserialize");
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_derive.jinja",
minijinja::context! {
derives => derives.join(", "),
},
));
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_struct_header.jinja",
minijinja::context! {
name => name,
},
));
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_discriminator_field.jinja",
minijinja::context! {
discriminator => discriminator,
},
));
for variant in &enum_def.variants {
if !variant.fields.is_empty() && variant.is_tuple {
if let Some(first_field) = variant.fields.first() {
let field_name = heck::AsSnakeCase(variant.name.as_str()).to_string();
let inner_ty = mapper.map_type(&first_field.ty);
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_variant_field.jinja",
minijinja::context! {
field_name => &field_name,
inner_ty => &inner_ty,
},
));
}
}
}
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_struct_footer.jinja",
minijinja::context! {},
));
out
}
fn gen_extendr_flat_data_enum_from_core(enum_def: &crate::core::ir::EnumDef, core_import: &str) -> String {
let name = &enum_def.name;
let core_path = format!("{core_import}::{name}");
let discriminator = enum_def.serde_tag.as_deref().unwrap_or("format_type");
let mut out = String::with_capacity(512);
let variant_wire_name = |variant: &crate::core::ir::EnumVariant| -> String {
if let Some(r) = &variant.serde_rename {
return r.clone();
}
match enum_def.serde_rename_all.as_deref() {
Some("snake_case") => heck::AsSnakeCase(variant.name.as_str()).to_string(),
Some("camelCase") => heck::AsLowerCamelCase(variant.name.as_str()).to_string(),
Some("SCREAMING_SNAKE_CASE") => heck::AsShoutySnakeCase(variant.name.as_str()).to_string(),
Some("kebab-case") => heck::AsKebabCase(variant.name.as_str()).to_string(),
_ => variant.name.clone(),
}
};
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_from_core_impl.jinja",
minijinja::context! {
core_path => &core_path,
name => name,
},
));
for variant in &enum_def.variants {
let field_name = heck::AsSnakeCase(variant.name.as_str()).to_string();
let wire_name = variant_wire_name(variant);
if variant.fields.is_empty() {
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_from_core_variant_unit.jinja",
minijinja::context! {
core_path => &core_path,
vname => &variant.name,
disc => discriminator,
wire => &wire_name,
},
));
} else if variant.is_tuple {
let first_field = variant.fields.first().unwrap();
let is_boxed = first_field.is_boxed;
let is_sanitized_to_string =
first_field.sanitized && matches!(first_field.ty, crate::core::ir::TypeRef::String);
let data_expr: String = if is_sanitized_to_string {
if is_boxed {
"format!(\"{:?}\", *_0)".to_string()
} else {
"format!(\"{:?}\", _0)".to_string()
}
} else if is_boxed {
"(*_0).into()".to_string()
} else {
"_0.into()".to_string()
};
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_from_core_variant_tuple.jinja",
minijinja::context! {
core_path => &core_path,
vname => &variant.name,
disc => discriminator,
wire => &wire_name,
fname => &field_name,
expr => &data_expr,
},
));
}
}
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_from_core_impl_footer.jinja",
minijinja::context! {},
));
out
}
fn gen_extendr_flat_data_enum_to_core(enum_def: &crate::core::ir::EnumDef, core_import: &str) -> String {
let name = &enum_def.name;
let core_path = format!("{core_import}::{name}");
let discriminator = enum_def.serde_tag.as_deref().unwrap_or("format_type");
let mut out = String::with_capacity(512);
let variant_wire_name = |variant: &crate::core::ir::EnumVariant| -> String {
if let Some(r) = &variant.serde_rename {
return r.clone();
}
match enum_def.serde_rename_all.as_deref() {
Some("snake_case") => heck::AsSnakeCase(variant.name.as_str()).to_string(),
Some("camelCase") => heck::AsLowerCamelCase(variant.name.as_str()).to_string(),
Some("SCREAMING_SNAKE_CASE") => heck::AsShoutySnakeCase(variant.name.as_str()).to_string(),
Some("kebab-case") => heck::AsKebabCase(variant.name.as_str()).to_string(),
_ => variant.name.clone(),
}
};
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_from_binding_impl.jinja",
minijinja::context! {
name => name,
core_path => &core_path,
discriminator => discriminator,
},
));
for variant in &enum_def.variants {
let field_name = heck::AsSnakeCase(variant.name.as_str()).to_string();
let wire_name = variant_wire_name(variant);
if variant.fields.is_empty() {
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_from_binding_variant_unit.jinja",
minijinja::context! {
wire => &wire_name,
vname => &variant.name,
},
));
} else if variant.is_tuple {
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_from_binding_variant_tuple.jinja",
minijinja::context! {
wire => &wire_name,
vname => &variant.name,
fname => &field_name,
},
));
}
}
out.push_str(&crate::backends::extendr::template_env::render(
"flat_enum_from_binding_impl_footer.jinja",
minijinja::context! {},
));
out
}
fn return_type_needs_json(
ret: &TypeRef,
extendr_incompatible_types: &AHashSet<String>,
enum_names: &AHashSet<String>,
opaque_types: &AHashSet<String>,
) -> bool {
match ret {
TypeRef::Named(n) => extendr_incompatible_types.contains(n.as_str()),
TypeRef::Vec(inner) => match inner.as_ref() {
TypeRef::Named(n) => extendr_incompatible_types.contains(n.as_str()),
TypeRef::Vec(_) => true,
_ => false,
},
TypeRef::Optional(inner) => match inner.as_ref() {
TypeRef::Named(n) if enum_names.contains(n.as_str()) => true,
TypeRef::Named(n) if !opaque_types.contains(n.as_str()) && !enum_names.contains(n.as_str()) => true,
_ => false,
},
_ => false,
}
}
fn gen_extendr_json_bridged_function(
func: &FunctionDef,
mapper: &dyn TypeMapper,
core_import: &str,
opaque_types: &AHashSet<String>,
cfg: &RustBindingConfig,
extendr_incompatible_types: &AHashSet<String>,
) -> String {
use crate::codegen::generators::binding_helpers::gen_call_args_cfg;
let err_map = ".map_err(|e| extendr_api::Error::Other(e.to_string().replace(\":\", \"_\").replace(\"/\", \"_\").replace(\"-\", \"_\").chars().take(255).collect::<String>()))";
let rt_new = format!("tokio::runtime::Runtime::new(){err_map}?");
let mut sig_params: Vec<String> = Vec::new();
let mut body_preamble = String::new();
for param in &func.params {
let needs_json = matches!(¶m.ty, TypeRef::Vec(inner)
if matches!(inner.as_ref(), TypeRef::Named(n) if !opaque_types.contains(n.as_str())));
if needs_json {
let core_ty_path = match ¶m.ty {
TypeRef::Vec(inner) => match inner.as_ref() {
TypeRef::Named(n) => format!("{core_import}::{n}"),
_ => unreachable!(),
},
_ => unreachable!(),
};
if param.optional {
sig_params.push(format!("{}: Option<String>", param.name));
body_preamble.push_str(&crate::backends::extendr::template_env::render(
"json_vec_optional_preamble.jinja",
minijinja::context! {
name => ¶m.name,
ty => &core_ty_path,
err_map => &err_map,
},
));
body_preamble.push_str(" ");
} else {
sig_params.push(format!("{}: String", param.name));
body_preamble.push_str(&crate::backends::extendr::template_env::render(
"json_vec_required_preamble.jinja",
minijinja::context! {
name => ¶m.name,
ty => &core_ty_path,
err_map => &err_map,
},
));
body_preamble.push_str(" ");
}
} else {
let ty_str = mapper.map_type(¶m.ty);
let sig_ty = if matches!(¶m.ty, TypeRef::Named(n) if !opaque_types.contains(n.as_str())) {
if param.optional {
format!("extendr_api::Nullable<&{ty_str}>")
} else {
format!("&{ty_str}")
}
} else if param.optional {
format!("Option<{ty_str}>")
} else {
ty_str
};
sig_params.push(format!("{}: {sig_ty}", param.name));
}
}
let call_args: Vec<String> = func
.params
.iter()
.map(|param| {
let needs_json = matches!(¶m.ty, TypeRef::Vec(inner)
if matches!(inner.as_ref(), TypeRef::Named(n) if !opaque_types.contains(n.as_str())));
if needs_json {
if param.optional {
format!("{}_core.as_deref().unwrap_or_default()", param.name)
} else {
format!("{}_core", param.name)
}
} else {
gen_call_args_cfg(
std::slice::from_ref(param),
opaque_types,
cfg.cast_uints_to_i32,
cfg.cast_large_ints_to_f64,
)
}
})
.collect();
let call_args_str = call_args.join(", ");
let core_fn_path = {
let path = func.rust_path.replace('-', "_");
if path.starts_with(core_import) {
path
} else {
format!("{core_import}::{}", func.name)
}
};
let mut named_let_bindings = String::new();
for param in &func.params {
let needs_json = matches!(¶m.ty, TypeRef::Vec(inner)
if matches!(inner.as_ref(), TypeRef::Named(n) if !opaque_types.contains(n.as_str())));
if !needs_json {
if let TypeRef::Named(n) = ¶m.ty {
if !opaque_types.contains(n.as_str()) {
if param.optional {
named_let_bindings.push_str(&crate::backends::extendr::template_env::render(
"named_let_optional_binding.jinja",
minijinja::context! {
name => ¶m.name,
ci => core_import,
n => n,
},
));
named_let_bindings.push_str(" ");
} else {
named_let_bindings.push_str(&crate::backends::extendr::template_env::render(
"named_let_required_binding.jinja",
minijinja::context! {
name => ¶m.name,
ci => core_import,
n => n,
},
));
named_let_bindings.push_str(" ");
}
}
}
}
}
let final_call_args: Vec<String> = func
.params
.iter()
.map(|param| {
let needs_json = matches!(¶m.ty, TypeRef::Vec(inner)
if matches!(inner.as_ref(), TypeRef::Named(n) if !opaque_types.contains(n.as_str())));
if needs_json {
if param.optional {
format!("{}_core.as_deref().unwrap_or_default()", param.name)
} else {
format!("{}_core", param.name)
}
} else if matches!(¶m.ty, TypeRef::Named(n) if !opaque_types.contains(n.as_str())) {
if param.optional {
format!("{}_core.as_ref()", param.name)
} else {
format!("&{}_core", param.name)
}
} else {
gen_call_args_cfg(
std::slice::from_ref(param),
opaque_types,
cfg.cast_uints_to_i32,
cfg.cast_large_ints_to_f64,
)
}
})
.collect();
let _ = call_args_str; let final_call_args_str = final_call_args.join(", ");
let (ret_type, result_convert) = match &func.return_type {
TypeRef::Optional(inner) if matches!(inner.as_ref(), TypeRef::Named(_)) => {
if func.error_type.is_some() {
let ser = format!(
"result.map(|v| serde_json::to_string(&v){err_map}).transpose()",
err_map = err_map
);
("Result<Option<String>>".to_string(), ser)
} else {
let ser = "result.map(|v| serde_json::to_string(&v).expect(\"serialization failed\"))".to_string();
("Option<String>".to_string(), ser)
}
}
_ => {
if func.error_type.is_some() {
let ser = format!("serde_json::to_string(&result){err_map}");
("Result<String>".to_string(), ser)
} else {
(
"String".to_string(),
"serde_json::to_string(&result).expect(\"serialization failed\")".to_string(),
)
}
}
};
let binding_conversion: Option<String> = match &func.return_type {
TypeRef::Named(n) if extendr_incompatible_types.contains(n.as_str()) => {
Some(format!("let result: {n} = result.into();"))
}
TypeRef::Vec(inner) => match inner.as_ref() {
TypeRef::Named(n) if extendr_incompatible_types.contains(n.as_str()) => Some(format!(
"let result: Vec<{n}> = result.into_iter().map(Into::into).collect();"
)),
_ => None,
},
_ => None,
};
let convert = binding_conversion.as_deref().unwrap_or("");
let core_call = format!("{core_fn_path}({final_call_args_str})");
let body = if func.is_async {
if func.error_type.is_some() {
format!(
"{body_preamble}{named_let_bindings}\
let rt = {rt_new};\n \
let result = rt.block_on(async {{ {core_call}.await{err_map} }})?;\n \
{convert}\n \
{result_convert}",
err_map = err_map,
result_convert = result_convert,
)
} else {
format!(
"{body_preamble}{named_let_bindings}\
let rt = {rt_new};\n \
let result = rt.block_on(async {{ {core_call}.await }});\n \
{convert}\n \
{result_convert}"
)
}
} else if func.error_type.is_some() {
match &func.return_type {
TypeRef::Optional(_) => {
format!(
"{body_preamble}{named_let_bindings}\
let result = {core_call}{err_map}?;\n \
{convert}\n \
{result_convert}"
)
}
_ => {
format!(
"{body_preamble}{named_let_bindings}\
let result = {core_call}{err_map}?;\n \
{convert}\n \
{result_convert}"
)
}
}
} else {
format!(
"{body_preamble}{named_let_bindings}\
let result = {core_call};\n \
{convert}\n \
{result_convert}"
)
};
let params_str = sig_params.join(", ");
let allow = if func.error_type.is_some() {
"#[allow(clippy::missing_errors_doc)]\n"
} else {
""
};
format!(
"{allow}#[extendr]\npub fn {}({params_str}) -> {ret_type} {{\n {body}\n}}",
func.name
)
}
pub(crate) struct TraitBridgeFn {
pub(crate) name: String,
pub(crate) params: Vec<String>,
}
pub(crate) fn collect_trait_bridge_fn_names(config: &ResolvedCrateConfig) -> ahash::AHashSet<String> {
let mut names = ahash::AHashSet::new();
for bridge_cfg in &config.trait_bridges {
if bridge_cfg.exclude_languages.iter().any(|l| l == "r" || l == "extendr") {
continue;
}
if let Some(name) = bridge_cfg.register_fn.as_deref() {
names.insert(name.to_string());
}
if let Some(name) = bridge_cfg.unregister_fn.as_deref() {
names.insert(name.to_string());
}
if let Some(name) = bridge_cfg.clear_fn.as_deref() {
names.insert(name.to_string());
}
}
names
}
pub(crate) fn collect_trait_bridge_functions(config: &ResolvedCrateConfig) -> Vec<TraitBridgeFn> {
let mut out = Vec::new();
for bridge_cfg in &config.trait_bridges {
if bridge_cfg.exclude_languages.iter().any(|l| l == "r" || l == "extendr") {
continue;
}
if let Some(name) = bridge_cfg.register_fn.as_deref() {
out.push(TraitBridgeFn {
name: name.to_string(),
params: vec!["r_backend".to_string()],
});
}
if let Some(name) = bridge_cfg.unregister_fn.as_deref() {
out.push(TraitBridgeFn {
name: name.to_string(),
params: vec!["name".to_string()],
});
}
if let Some(name) = bridge_cfg.clear_fn.as_deref() {
out.push(TraitBridgeFn {
name: name.to_string(),
params: Vec::new(),
});
}
}
out
}
fn collect_excluded_class_types(api: &ApiSurface) -> ahash::AHashSet<String> {
let opaque_types: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque)
.map(|t| t.name.clone())
.collect();
let enum_names: ahash::AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
let arc_incompatible: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque && t.cfg.is_some())
.map(|t| t.name.clone())
.collect();
let is_struct_like =
|n: &str| -> bool { !opaque_types.contains(n) && !enum_names.contains(n) && !arc_incompatible.contains(n) };
let is_native_incompatible = |ty: &TypeRef| -> bool {
match ty {
TypeRef::Vec(inner) => match inner.as_ref() {
TypeRef::Named(n) if is_struct_like(n) => true,
TypeRef::Vec(_) => true, _ => false,
},
TypeRef::Optional(inner) => match inner.as_ref() {
TypeRef::Vec(inner2) => match inner2.as_ref() {
TypeRef::Named(n) if is_struct_like(n) => true,
TypeRef::Vec(_) => true, _ => false,
},
_ => false,
},
_ => false,
}
};
let mut excluded: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_trait)
.map(|t| t.name.clone())
.collect();
for t in &arc_incompatible {
excluded.insert(t.clone());
}
for t in &api.types {
if t.is_opaque || t.is_trait {
continue;
}
if t.fields.iter().any(|f| is_native_incompatible(&f.ty)) {
excluded.insert(t.name.clone());
}
}
excluded
}
fn method_is_excluded_from_impl(method: &crate::core::ir::MethodDef, api: &ApiSurface) -> bool {
let opaque_types: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque)
.map(|t| t.name.clone())
.collect();
let enum_names: ahash::AHashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
let arc_incompatible: ahash::AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque && t.cfg.is_some())
.map(|t| t.name.clone())
.collect();
let references_arc_incompatible = |ty: &TypeRef| -> bool {
match ty {
TypeRef::Named(n) => arc_incompatible.contains(n),
TypeRef::Optional(inner) => matches!(inner.as_ref(), TypeRef::Named(n) if arc_incompatible.contains(n)),
_ => false,
}
};
let references_enum = |ty: &TypeRef| -> bool {
match ty {
TypeRef::Named(n) => enum_names.contains(n.as_str()),
TypeRef::Optional(inner) => matches!(inner.as_ref(), TypeRef::Named(n) if enum_names.contains(n.as_str())),
_ => false,
}
};
let param_is_owned_struct = |ty: &TypeRef| -> bool {
let is_non_opaque_struct =
|n: &str| !opaque_types.contains(n) && !enum_names.contains(n) && !arc_incompatible.contains(n);
match ty {
TypeRef::Named(n) => is_non_opaque_struct(n),
TypeRef::Optional(inner) => matches!(inner.as_ref(), TypeRef::Named(n) if is_non_opaque_struct(n)),
_ => false,
}
};
if references_arc_incompatible(&method.return_type)
|| method.params.iter().any(|p| references_arc_incompatible(&p.ty))
{
return true;
}
if references_enum(&method.return_type)
|| method
.params
.iter()
.any(|p| references_enum(&p.ty) || param_is_owned_struct(&p.ty))
{
return true;
}
if method.sanitized {
return true;
}
false
}
fn r_type_description(ty: &TypeRef) -> String {
match ty {
TypeRef::Bytes => "Raw vector of bytes.".to_string(),
TypeRef::String => "Character string.".to_string(),
TypeRef::Char => "Single-character string.".to_string(),
TypeRef::Primitive(p) => match p {
crate::core::ir::PrimitiveType::Bool => "Logical (TRUE/FALSE).".to_string(),
crate::core::ir::PrimitiveType::F32 | crate::core::ir::PrimitiveType::F64 => "Numeric.".to_string(),
_ => "Integer.".to_string(),
},
TypeRef::Optional(inner) => {
let inner_desc = r_type_description(inner);
let trimmed = inner_desc.trim_end_matches('.');
let body = if matches!(**inner, TypeRef::Named(_)) {
trimmed.to_string()
} else {
match trimmed.chars().next() {
Some(c) => {
let mut s = c.to_lowercase().collect::<String>();
s.push_str(&trimmed[c.len_utf8()..]);
s
}
None => String::new(),
}
};
format!("Optional {body}. Defaults to NULL.")
}
TypeRef::Vec(inner) => {
let inner_desc = r_type_description(inner);
let trimmed = inner_desc.trim_end_matches('.');
format!("List of {}.", trimmed.to_lowercase())
}
TypeRef::Map(_, _) => "Named list.".to_string(),
TypeRef::Named(name) => format!("{name} object (list with class attribute)."),
TypeRef::Path => "File path as character string.".to_string(),
TypeRef::Unit => "Invisible NULL.".to_string(),
TypeRef::Json => "JSON-serializable value.".to_string(),
TypeRef::Duration => "Numeric duration in seconds.".to_string(),
}
}
fn title_case_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn push_roxygen_inline_multiline(block: &mut String, text: &str) {
let mut lines = text.lines();
if let Some(first) = lines.next() {
block.push_str(first.trim_end());
}
for line in lines {
block.push('\n');
block.push_str("#' ");
block.push_str(line.trim_end());
}
}
fn r_roxygen_block(func_name: &str, doc: &str, params: &[ParamDef], return_type: &TypeRef) -> String {
let mut block = String::with_capacity(256);
let trimmed_doc = doc.trim();
let sections = parse_rustdoc_sections(trimmed_doc);
let summary = sections.summary.trim();
let (title, description) = if summary.is_empty() {
(func_name.to_string(), String::new())
} else {
let mut parts = summary.splitn(2, '\n');
let raw_title = parts.next().unwrap_or("").trim().trim_end_matches('.');
let title = title_case_first(raw_title);
let description = parts.next().map(str::trim).unwrap_or("").to_string();
(title, description)
};
block.push_str("#' ");
block.push_str(&title);
block.push('\n');
if !description.is_empty() {
block.push_str("#'\n");
for line in description.lines() {
let line = line.trim_end();
if line.is_empty() {
block.push_str("#'\n");
} else {
block.push_str("#' ");
block.push_str(line);
block.push('\n');
}
}
}
let mut param_docs: HashMap<String, String> = HashMap::new();
if let Some(args_body) = sections.arguments.as_deref() {
for (name, desc) in parse_arguments_bullets(args_body) {
if !desc.is_empty() {
param_docs.insert(name, desc);
}
}
}
for param in params {
block.push_str("#' @param ");
block.push_str(¶m.name);
block.push(' ');
if let Some(desc) = param_docs.get(¶m.name) {
push_roxygen_inline_multiline(&mut block, desc);
if !desc.trim_end().ends_with('.') {
block.push('.');
}
} else {
block.push_str(&r_type_description(¶m.ty));
}
block.push('\n');
}
block.push_str("#' @return ");
if let Some(ret) = sections.returns.as_deref() {
let ret = ret.trim();
push_roxygen_inline_multiline(&mut block, ret);
if !ret.ends_with('.') {
block.push('.');
}
} else {
block.push_str(&r_type_description(return_type));
}
block.push('\n');
if let Some(err) = sections.errors.as_deref() {
block.push_str("#'\n#' @section Errors:\n");
for line in err.trim().lines() {
let line = line.trim_end();
if line.is_empty() {
block.push_str("#'\n");
} else {
block.push_str("#' ");
block.push_str(line);
block.push('\n');
}
}
}
block.push_str("#' @export\n");
block
}
fn r_field_one_liner(field_name: &str, doc: &str) -> String {
let trimmed = doc.trim();
if trimmed.is_empty() {
return field_name.to_string();
}
let paragraph: Vec<&str> = trimmed
.lines()
.take_while(|l| !l.trim().is_empty())
.map(str::trim)
.collect();
if paragraph.is_empty() {
field_name.to_string()
} else {
let mut result = paragraph.join(" ");
let max_desc_len = 109_usize.saturating_sub(field_name.len());
if result.len() > max_desc_len {
result.truncate(max_desc_len);
if let Some(last_space) = result.rfind(' ') {
result.truncate(last_space);
}
}
result
}
}
fn r_class_roxygen_block(typ: &TypeDef) -> String {
let mut block = String::with_capacity(256);
let sections = parse_rustdoc_sections(typ.doc.trim());
let summary = sections.summary.trim();
let (title, description) = if summary.is_empty() {
(typ.name.clone(), String::new())
} else {
let mut parts = summary.splitn(2, '\n');
let raw_title = parts.next().unwrap_or("").trim().trim_end_matches('.');
let title = title_case_first(raw_title);
let description = parts.next().map(str::trim).unwrap_or("").to_string();
(title, description)
};
block.push_str("#' ");
block.push_str(&title);
block.push('\n');
if !description.is_empty() {
block.push_str("#'\n");
for line in description.lines() {
let line = line.trim_end();
if line.is_empty() {
block.push_str("#'\n");
} else {
block.push_str("#' ");
block.push_str(line);
block.push('\n');
}
}
}
for field in &typ.fields {
if field.binding_excluded {
continue;
}
let rname = field.name.trim_start_matches('_');
block.push_str("#' @field ");
block.push_str(rname);
block.push(' ');
block.push_str(&r_field_one_liner(rname, &field.doc));
block.push('\n');
}
block.push_str("#' @export\n");
block
}
fn r_enum_roxygen_block(enum_def: &EnumDef, include_variants_as_fields: bool) -> String {
let mut block = String::with_capacity(256);
let sections = parse_rustdoc_sections(enum_def.doc.trim());
let summary = sections.summary.trim();
let (title, description) = if summary.is_empty() {
(enum_def.name.clone(), String::new())
} else {
let mut parts = summary.splitn(2, '\n');
let raw_title = parts.next().unwrap_or("").trim().trim_end_matches('.');
let title = title_case_first(raw_title);
let description = parts.next().map(str::trim).unwrap_or("").to_string();
(title, description)
};
block.push_str("#' ");
block.push_str(&title);
block.push('\n');
if !description.is_empty() {
block.push_str("#'\n");
for line in description.lines() {
let line = line.trim_end();
if line.is_empty() {
block.push_str("#'\n");
} else {
block.push_str("#' ");
block.push_str(line);
block.push('\n');
}
}
}
if include_variants_as_fields {
for variant in &enum_def.variants {
block.push_str("#' @field ");
block.push_str(&variant.name);
block.push(' ');
block.push_str(&r_field_one_liner(&variant.name, &variant.doc));
block.push('\n');
}
}
block.push_str("#' @export\n");
block
}
fn gen_extendr_wrappers_r(
api: &ApiSurface,
package_name: &str,
input_type_names: &ahash::AHashSet<String>,
trait_bridge_fns: &[TraitBridgeFn],
r_exclude_functions: &ahash::AHashSet<String>,
) -> String {
let mut out = String::with_capacity(8 * 1024);
out.push_str("# Generated by extendr: Do not edit by hand\n");
out.push_str("#\n");
out.push_str("# This file is regenerated by alef on every `alef generate` run.\n");
out.push_str("# It mirrors the output of `rextendr::document()` and binds every\n");
out.push_str("# wrap__<symbol> entry registered in extendr_module! to an R-callable\n");
out.push_str("# function or class env.\n\n");
out.push_str(&crate::backends::extendr::template_env::render(
"r_use_dyn_lib.jinja",
minijinja::context! { package_name => package_name },
));
out.push_str("NULL\n\n");
let bridge_fn_names: ahash::AHashSet<&str> = trait_bridge_fns.iter().map(|tb| tb.name.as_str()).collect();
for func in &api.functions {
if bridge_fn_names.contains(func.name.as_str()) {
continue;
}
if r_exclude_functions.contains(&func.name) {
continue;
}
let params: Vec<&str> = func.params.iter().map(|p| p.name.as_str()).collect();
let params_sig = params.join(", ");
let mut call_args = vec![format!("\"wrap__{}\"", func.name)];
for p in ¶ms {
call_args.push((*p).to_string());
}
call_args.push(format!("PACKAGE = \"{package_name}\""));
let call_args_str = call_args.join(", ");
let roxygen_block = r_roxygen_block(&func.name, &func.doc, &func.params, &func.return_type);
out.push_str(&crate::backends::extendr::template_env::render(
"r_free_function_wrapper.jinja",
minijinja::context! {
func_name => &func.name,
params_sig => params_sig,
call_args_str => call_args_str,
roxygen_block => roxygen_block,
},
));
}
for bridge_fn in trait_bridge_fns {
let params_sig = bridge_fn.params.join(", ");
let mut call_args = vec![format!("\"wrap__{}\"", bridge_fn.name)];
for p in &bridge_fn.params {
call_args.push(p.clone());
}
call_args.push(format!("PACKAGE = \"{package_name}\""));
let call_args_str = call_args.join(", ");
let mut roxygen_block = String::with_capacity(256);
roxygen_block.push_str(&format!("#' {}\n", bridge_fn.name));
roxygen_block.push_str("#'\n");
if bridge_fn.name.starts_with("register_") {
roxygen_block.push_str("#' Register an R-side plugin implementation. Pass a named list whose entries\n");
roxygen_block
.push_str("#' implement the trait's required methods (e.g. `list(name = function() \"my\", ...)`).\n");
roxygen_block.push_str("#'\n");
roxygen_block.push_str("#' @param r_backend Named list of R closures implementing the trait surface.\n");
} else if bridge_fn.name.starts_with("unregister_") {
roxygen_block.push_str("#' Unregister a previously registered plugin by name.\n");
roxygen_block.push_str("#'\n");
roxygen_block.push_str("#' @param name Plugin name string as returned by the backend's `name()` method.\n");
} else if bridge_fn.name.starts_with("clear_") {
roxygen_block
.push_str("#' Remove every registered plugin of this type. Typically used in test teardown.\n");
}
roxygen_block.push_str("#'\n");
roxygen_block.push_str("#' @return Invisible NULL on success; raises an R error on failure.\n");
roxygen_block.push_str("#' @export\n");
out.push_str(&crate::backends::extendr::template_env::render(
"r_free_function_wrapper.jinja",
minijinja::context! {
func_name => &bridge_fn.name,
params_sig => params_sig,
call_args_str => call_args_str,
roxygen_block => roxygen_block,
},
));
}
let s3_pairs = collect_s3_methods(api, trait_bridge_fns);
let s3_pairs_by_type: ahash::AHashMap<String, Vec<String>> = {
let mut map: ahash::AHashMap<String, Vec<String>> = ahash::AHashMap::new();
for (method_name, type_name) in &s3_pairs {
map.entry(type_name.clone()).or_default().push(method_name.clone());
}
map
};
let excluded = collect_excluded_class_types(api);
for typ in &api.types {
if typ.is_trait || excluded.contains(&typ.name) {
continue;
}
let class_roxygen = r_class_roxygen_block(typ);
out.push_str(&crate::backends::extendr::template_env::render(
"r_type_class_env.jinja",
minijinja::context! {
type_name => &typ.name,
roxygen_block => class_roxygen,
},
));
for method in &typ.methods {
if method_is_excluded_from_impl(method, api) {
continue;
}
let params: Vec<&str> = method.params.iter().map(|p| p.name.as_str()).collect();
let params_sig = params.join(", ");
let mut call_args = vec![format!(
"\"wrap__{type_name}__{method_name}\"",
type_name = typ.name,
method_name = method.name,
)];
if !method.is_static {
call_args.push("self".to_string());
}
for p in ¶ms {
call_args.push((*p).to_string());
}
call_args.push(format!("PACKAGE = \"{package_name}\""));
let call_args_str = call_args.join(", ");
out.push_str(&crate::backends::extendr::template_env::render(
"r_method_binding.jinja",
minijinja::context! {
type_name => &typ.name,
method_name => &method.name,
params_sig => params_sig,
call_args_str => call_args_str,
},
));
}
if typ.has_default && !typ.fields.is_empty() && input_type_names.contains(&typ.name) {
out.push_str(&crate::backends::extendr::template_env::render(
"r_from_json_factory.jinja",
minijinja::context! {
type_name => &typ.name,
package_name => package_name,
},
));
}
out.push_str(&crate::backends::extendr::template_env::render(
"r_dollar_dispatch.jinja",
minijinja::context! { type_name => &typ.name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_bracket_dispatch.jinja",
minijinja::context! { type_name => &typ.name },
));
if let Some(method_names) = s3_pairs_by_type.get(&typ.name) {
for method_name in method_names {
out.push_str(&crate::backends::extendr::template_env::render(
"r_s3_method.jinja",
minijinja::context! { name => method_name, type_name => &typ.name },
));
}
}
}
for e in &api.enums {
if !is_flat_data_enum(e) {
continue;
}
let type_name = &e.name;
let enum_roxygen = r_enum_roxygen_block(e, true);
out.push_str(&crate::backends::extendr::template_env::render(
"r_type_class_env.jinja",
minijinja::context! {
type_name => type_name,
roxygen_block => enum_roxygen,
},
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_dollar_dispatch.jinja",
minijinja::context! { type_name => type_name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_bracket_dispatch.jinja",
minijinja::context! { type_name => type_name },
));
}
for e in &api.enums {
if !is_json_passthrough_data_enum(e) {
continue;
}
let type_name = &e.name;
let enum_roxygen = r_enum_roxygen_block(e, false);
out.push_str(&crate::backends::extendr::template_env::render(
"r_type_class_env.jinja",
minijinja::context! {
type_name => type_name,
roxygen_block => enum_roxygen,
},
));
for method_name in ["default", "from_json"] {
let params_sig = if method_name == "from_json" { "json" } else { "" };
let mut call_args = vec![format!("\"wrap__{type_name}__{method_name}\"")];
if method_name == "from_json" {
call_args.push("json".to_string());
}
call_args.push(format!("PACKAGE = \"{package_name}\""));
let call_args_str = call_args.join(", ");
out.push_str(&crate::backends::extendr::template_env::render(
"r_method_binding.jinja",
minijinja::context! {
type_name => type_name,
method_name => method_name,
params_sig => params_sig,
call_args_str => call_args_str,
},
));
}
out.push_str(&crate::backends::extendr::template_env::render(
"r_dollar_dispatch.jinja",
minijinja::context! { type_name => type_name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_bracket_dispatch.jinja",
minijinja::context! { type_name => type_name },
));
}
for generic_name in unique_s3_generic_names(&s3_pairs) {
out.push_str(&crate::backends::extendr::template_env::render(
"r_s3_generic.jinja",
minijinja::context! { name => generic_name },
));
}
out
}
fn collect_s3_methods(api: &ApiSurface, trait_bridge_fns: &[TraitBridgeFn]) -> Vec<(String, String)> {
let excluded_types = collect_excluded_class_types(api);
let mut reserved: ahash::AHashSet<String> = api.functions.iter().map(|f| f.name.clone()).collect();
for bridge_fn in trait_bridge_fns {
reserved.insert(bridge_fn.name.clone());
}
let mut pairs: Vec<(String, String)> = Vec::new();
for typ in &api.types {
if typ.is_trait || excluded_types.contains(&typ.name) {
continue;
}
for method in &typ.methods {
if method.is_static || method_is_excluded_from_impl(method, api) {
continue;
}
if reserved.contains(&method.name) {
continue;
}
pairs.push((method.name.clone(), typ.name.clone()));
}
}
pairs
}
fn unique_s3_generic_names(pairs: &[(String, String)]) -> Vec<String> {
let mut names: Vec<String> = pairs.iter().map(|(name, _)| name.clone()).collect();
names.sort();
names.dedup();
names
}
fn gen_namespace(
api: &ApiSurface,
package_name: &str,
trait_bridge_fns: &[TraitBridgeFn],
r_exclude_functions: &ahash::AHashSet<String>,
) -> String {
let mut out = String::with_capacity(2 * 1024);
out.push_str("# Generated by alef — do not edit.\n\n");
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_use_dyn_lib.jinja",
minijinja::context! { package_name => package_name },
));
out.push('\n');
let bridge_fn_names: ahash::AHashSet<&str> = trait_bridge_fns.iter().map(|tb| tb.name.as_str()).collect();
for func in &api.functions {
if bridge_fn_names.contains(func.name.as_str()) {
continue;
}
if r_exclude_functions.contains(&func.name) {
continue;
}
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_export.jinja",
minijinja::context! { name => &func.name },
));
}
for bridge_fn in trait_bridge_fns {
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_export.jinja",
minijinja::context! { name => &bridge_fn.name },
));
}
let excluded = collect_excluded_class_types(api);
for typ in &api.types {
if typ.is_trait || excluded.contains(&typ.name) {
continue;
}
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_export.jinja",
minijinja::context! { name => &typ.name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_s3method.jinja",
minijinja::context! { method_type => "$", name => &typ.name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_s3method.jinja",
minijinja::context! { method_type => "[[", name => &typ.name },
));
}
for e in &api.enums {
if !is_flat_data_enum(e) {
continue;
}
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_export.jinja",
minijinja::context! { name => &e.name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_s3method.jinja",
minijinja::context! { method_type => "$", name => &e.name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_s3method.jinja",
minijinja::context! { method_type => "[[", name => &e.name },
));
}
for e in &api.enums {
if !is_json_passthrough_data_enum(e) {
continue;
}
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_export.jinja",
minijinja::context! { name => &e.name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_s3method.jinja",
minijinja::context! { method_type => "$", name => &e.name },
));
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_s3method.jinja",
minijinja::context! { method_type => "[[", name => &e.name },
));
}
let s3_pairs = collect_s3_methods(api, trait_bridge_fns);
for generic_name in unique_s3_generic_names(&s3_pairs) {
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_export.jinja",
minijinja::context! { name => &generic_name },
));
}
for (method_name, type_name) in &s3_pairs {
out.push_str(&crate::backends::extendr::template_env::render(
"r_namespace_s3method_named.jinja",
minijinja::context! { method_name => method_name, type_name => type_name },
));
}
out
}
#[cfg(test)]
mod tests {
use super::ExtendrBackend;
use crate::core::backend::Backend;
use crate::core::config::ResolvedCrateConfig;
use crate::core::config::new_config::NewAlefConfig;
use crate::core::ir::*;
fn resolved_one(toml: &str) -> ResolvedCrateConfig {
let cfg: NewAlefConfig = toml::from_str(toml).unwrap();
cfg.resolve().unwrap().remove(0)
}
fn make_config() -> ResolvedCrateConfig {
resolved_one(
r#"
[workspace]
languages = ["r"]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
[crates.r]
package_name = "testlib"
"#,
)
}
fn make_field(name: &str, ty: TypeRef, optional: bool) -> FieldDef {
FieldDef {
name: name.to_string(),
ty,
optional,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: CoreWrapper::None,
vec_inner_core_wrapper: CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
binding_excluded: false,
binding_exclusion_reason: None,
original_type: None,
}
}
fn make_api_surface() -> ApiSurface {
ApiSurface {
crate_name: "test_lib".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "Config".to_string(),
rust_path: "test_lib::Config".to_string(),
original_rust_path: String::new(),
fields: vec![make_field("timeout", TypeRef::Primitive(PrimitiveType::U32), false)],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
binding_excluded: false,
binding_exclusion_reason: None,
}],
functions: vec![FunctionDef {
name: "process".to_string(),
rust_path: "test_lib::process".to_string(),
original_rust_path: String::new(),
params: vec![],
return_type: TypeRef::String,
is_async: false,
error_type: None,
doc: String::new(),
cfg: None,
sanitized: false,
return_sanitized: false,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
binding_excluded: false,
binding_exclusion_reason: None,
}],
enums: vec![],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
}
}
#[test]
fn generates_extendr_module_registration() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_surface();
let files = backend.generate_bindings(&api, &config).unwrap();
assert_eq!(files.len(), 1);
let content = &files[0].content;
assert!(content.contains("extendr_module!"), "must emit extendr_module! macro");
assert!(content.contains("mod testlib"), "module name must match r_package_name");
}
#[test]
fn generates_extendr_function_attribute() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_surface();
let files = backend.generate_bindings(&api, &config).unwrap();
let content = &files[0].content;
assert!(
content.contains("#[extendr]"),
"functions must carry #[extendr] attribute"
);
assert!(content.contains("fn process"), "process function must be generated");
}
#[test]
fn r_package_name_drives_output_path() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_surface();
let files = backend.generate_bindings(&api, &config).unwrap();
assert!(
files[0].path.to_string_lossy().ends_with("lib.rs"),
"output file must be lib.rs"
);
}
#[test]
fn generate_public_api_uses_r_package_name() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_surface();
let files = backend.generate_public_api(&api, &config).unwrap();
let paths: Vec<String> = files.iter().map(|f| f.path.to_string_lossy().into_owned()).collect();
assert!(
paths.iter().any(|p| p.ends_with("testlib.R")),
"public API file must include {{package_name}}.R, got {paths:?}"
);
assert!(
paths.iter().any(|p| p.ends_with("extendr-wrappers.R")),
"public API file must include extendr-wrappers.R, got {paths:?}"
);
assert!(
paths.iter().any(|p| p.ends_with("NAMESPACE")),
"public API file must include NAMESPACE, got {paths:?}"
);
}
#[test]
fn extendr_wrappers_emits_function_call_binding() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_surface();
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
assert!(
wrappers.content.contains("process <- function()"),
"free function must produce a wrapper: {}",
wrappers.content
);
assert!(
wrappers.content.contains(".Call(\"wrap__process\""),
"wrapper must invoke the wrap__ symbol: {}",
wrappers.content
);
assert!(
wrappers.content.contains("Config <- new.env(parent = emptyenv())"),
"non-trait class must be registered as an env: {}",
wrappers.content
);
}
#[test]
fn extendr_wrappers_emits_roxygen_doc_block_for_free_functions() {
let backend = ExtendrBackend;
let config = make_config();
let api = ApiSurface {
crate_name: "test_lib".to_string(),
version: "0.1.0".to_string(),
types: vec![],
functions: vec![FunctionDef {
name: "extract_bytes".to_string(),
rust_path: "test_lib::extract_bytes".to_string(),
original_rust_path: String::new(),
params: vec![
ParamDef {
name: "bytes".to_string(),
ty: TypeRef::Bytes,
optional: false,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
map_is_ahash: false,
map_key_is_cow: false,
},
ParamDef {
name: "mime_type".to_string(),
ty: TypeRef::Optional(Box::new(TypeRef::String)),
optional: true,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
map_is_ahash: false,
map_key_is_cow: false,
},
ParamDef {
name: "config".to_string(),
ty: TypeRef::Optional(Box::new(TypeRef::Named("ExtractionConfig".to_string()))),
optional: true,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
map_is_ahash: false,
map_key_is_cow: false,
},
],
return_type: TypeRef::Named("ExtractionResult".to_string()),
is_async: false,
error_type: None,
doc: "Extract text from raw bytes.\n\nDetect the MIME type of the input bytes\nand run the appropriate extractor.".to_string(),
cfg: None,
sanitized: false,
return_sanitized: false,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
binding_excluded: false,
binding_exclusion_reason: None,
}],
enums: vec![],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
};
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
content.contains("#' Extract text from raw bytes"),
"title line derived from Rust doc comment must be emitted:\n{content}"
);
assert!(
content.contains("#' Detect the MIME type of the input bytes"),
"description from Rust doc comment must be emitted:\n{content}"
);
assert!(
content.contains("#' @param bytes Raw vector of bytes."),
"@param for bytes must describe the type:\n{content}"
);
assert!(
content.contains("#' @param mime_type Optional character string."),
"@param for optional string must include `Optional` qualifier:\n{content}"
);
assert!(
content.contains("#' @param config Optional ExtractionConfig object"),
"@param for named optional type must reference the named type:\n{content}"
);
assert!(
content.contains("#' @return ExtractionResult object"),
"@return must describe the return type:\n{content}"
);
assert!(
content.contains("#' @export"),
"@export tag must be preserved:\n{content}"
);
for line in content.lines() {
if let Some(rest) = line.strip_prefix("#' @param ") {
let mut parts = rest.splitn(2, ' ');
let _name = parts.next();
let description = parts.next().unwrap_or("").trim();
assert!(
!description.is_empty(),
"@param line must include a description, got: {line:?}\nfull content:\n{content}"
);
}
}
}
#[test]
fn extendr_wrappers_emits_placeholder_title_when_doc_is_empty() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_surface();
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
content.contains("#' process"),
"fallback title (function name) must be emitted when doc is empty:\n{content}"
);
assert!(
content.contains("#' @return Character string."),
"@return must be emitted even without a doc comment:\n{content}"
);
}
#[test]
fn namespace_exports_functions_and_classes() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_surface();
let files = backend.generate_public_api(&api, &config).unwrap();
let namespace = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("NAMESPACE"))
.expect("NAMESPACE must be generated");
assert!(
namespace.content.contains("export(process)"),
"free function must be exported: {}",
namespace.content
);
assert!(
namespace.content.contains("export(Config)"),
"class env must be exported: {}",
namespace.content
);
assert!(
namespace.content.contains("S3method(\"$\", Config)"),
"S3 dispatch operator must be registered: {}",
namespace.content
);
assert!(
namespace.content.contains("useDynLib(testlib, .registration = TRUE)"),
"NAMESPACE must contain bare useDynLib directive: {}",
namespace.content
);
assert!(
!namespace.content.contains("#' @useDynLib"),
"NAMESPACE must not contain roxygen2 useDynLib form: {}",
namespace.content
);
}
fn make_instance_method(name: &str) -> MethodDef {
MethodDef {
name: name.to_string(),
params: vec![],
return_type: TypeRef::Primitive(PrimitiveType::Bool),
is_async: false,
is_static: false,
error_type: None,
doc: String::new(),
sanitized: false,
receiver: Some(ReceiverKind::Ref),
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
}
}
fn make_api_with_instance_method() -> ApiSurface {
ApiSurface {
crate_name: "test_lib".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "HeaderMetadata".to_string(),
rust_path: "test_lib::HeaderMetadata".to_string(),
original_rust_path: String::new(),
fields: vec![make_field("level", TypeRef::Primitive(PrimitiveType::U32), false)],
methods: vec![make_instance_method("is_valid")],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
binding_excluded: false,
binding_exclusion_reason: None,
}],
functions: vec![],
enums: vec![],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
}
}
#[test]
fn extendr_wrappers_emits_s3_generic_and_method_for_instance_methods() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_with_instance_method();
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
content.contains("is_valid <- function(x, ...) UseMethod(\"is_valid\")"),
"S3 generic must be emitted for instance methods:\n{content}"
);
assert!(
content.contains("is_valid.HeaderMetadata <- function(x, ...) x$is_valid(...)"),
"S3 class method must forward to the env-class binding:\n{content}"
);
}
#[test]
fn extendr_wrappers_skips_s3_wrappers_for_static_methods() {
let backend = ExtendrBackend;
let config = make_config();
let mut api = make_api_with_instance_method();
let static_method = MethodDef {
is_static: true,
..make_instance_method("default")
};
api.types[0].methods.push(static_method);
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
!content.contains("default <- function(x, ...) UseMethod"),
"must not emit S3 generic for static methods:\n{content}"
);
assert!(
!content.contains("default.HeaderMetadata <-"),
"must not emit S3 class method for static methods:\n{content}"
);
}
#[test]
fn extendr_wrappers_emits_one_generic_per_unique_method_name() {
let backend = ExtendrBackend;
let config = make_config();
let mut api = make_api_with_instance_method();
let second_type = TypeDef {
name: "LinkMetadata".to_string(),
rust_path: "test_lib::LinkMetadata".to_string(),
methods: vec![make_instance_method("is_valid")],
..api.types[0].clone()
};
api.types.push(second_type);
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
let generic_count = content.matches("is_valid <- function(x, ...) UseMethod").count();
assert_eq!(
generic_count, 1,
"exactly one S3 generic per unique method name, got {generic_count}:\n{content}"
);
assert!(
content.contains("is_valid.HeaderMetadata <- function(x, ...) x$is_valid(...)"),
"S3 method for HeaderMetadata must be emitted:\n{content}"
);
assert!(
content.contains("is_valid.LinkMetadata <- function(x, ...) x$is_valid(...)"),
"S3 method for LinkMetadata must be emitted:\n{content}"
);
}
#[test]
fn namespace_exports_s3_generics_and_methods_for_instance_methods() {
let backend = ExtendrBackend;
let config = make_config();
let api = make_api_with_instance_method();
let files = backend.generate_public_api(&api, &config).unwrap();
let namespace = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("NAMESPACE"))
.expect("NAMESPACE must be generated");
let content = &namespace.content;
assert!(
content.contains("export(is_valid)"),
"S3 generic must be exported by name: {content}"
);
assert!(
content.contains("S3method(is_valid, HeaderMetadata)"),
"S3 class method must be registered: {content}"
);
}
#[test]
fn extendr_wrappers_emits_roxygen_class_block_with_field_lines_for_struct() {
let backend = ExtendrBackend;
let config = make_config();
let api = ApiSurface {
crate_name: "test_lib".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "ServerConfig".to_string(),
rust_path: "test_lib::ServerConfig".to_string(),
original_rust_path: String::new(),
fields: vec![
FieldDef {
doc: "TCP port the server binds to.".to_string(),
..make_field("port", TypeRef::Primitive(PrimitiveType::U32), false)
},
FieldDef {
doc: "Maximum number of in-flight requests.\n\nApplies to all listener sockets.".to_string(),
..make_field("max_connections", TypeRef::Primitive(PrimitiveType::U32), false)
},
],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: "Server configuration.\n\nHolds tunable parameters for the network listener.".to_string(),
cfg: None,
binding_excluded: false,
binding_exclusion_reason: None,
}],
functions: vec![],
enums: vec![],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
};
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
content.contains("#' Server configuration"),
"class title from struct doc must be emitted:\n{content}"
);
assert!(
content.contains("#' Holds tunable parameters for the network listener."),
"class description must be emitted:\n{content}"
);
assert!(
content.contains("#' @field port TCP port the server binds to."),
"@field with single-line doc must be emitted:\n{content}"
);
assert!(
content.contains("#' @field max_connections Maximum number of in-flight requests."),
"@field must collapse multi-paragraph doc to the first paragraph:\n{content}"
);
assert!(
content.contains("ServerConfig <- new.env(parent = emptyenv())"),
"class env definition must still be emitted:\n{content}"
);
}
#[test]
fn extendr_wrappers_emits_param_doc_from_arguments_section_for_function() {
let backend = ExtendrBackend;
let config = make_config();
let api = ApiSurface {
crate_name: "test_lib".to_string(),
version: "0.1.0".to_string(),
types: vec![],
functions: vec![FunctionDef {
name: "render".to_string(),
rust_path: "test_lib::render".to_string(),
original_rust_path: String::new(),
params: vec![ParamDef {
name: "template".to_string(),
ty: TypeRef::String,
optional: false,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
map_is_ahash: false,
map_key_is_cow: false,
}],
return_type: TypeRef::String,
is_async: false,
error_type: None,
doc: "Render a template to a string.\n\n# Arguments\n\n* `template` - Mustache template source.\n\n# Returns\n\nThe fully interpolated output.".to_string(),
cfg: None,
sanitized: false,
return_sanitized: false,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
binding_excluded: false,
binding_exclusion_reason: None,
}],
enums: vec![],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
};
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
content.contains("#' @param template Mustache template source."),
"@param must use description from `# Arguments` bullet:\n{content}"
);
assert!(
content.contains("#' @return The fully interpolated output."),
"@return must use prose from `# Returns` section:\n{content}"
);
assert!(
!content.contains("#' # Arguments"),
"raw `# Arguments` heading must not appear in roxygen output:\n{content}"
);
assert!(
!content.contains("#' # Returns"),
"raw `# Returns` heading must not appear in roxygen output:\n{content}"
);
}
#[test]
fn extendr_wrappers_emits_roxygen_block_for_flat_data_enum_with_variant_fields() {
let backend = ExtendrBackend;
let config = make_config();
let api = ApiSurface {
crate_name: "test_lib".to_string(),
version: "0.1.0".to_string(),
types: vec![],
functions: vec![],
enums: vec![EnumDef {
name: "Payload".to_string(),
rust_path: "test_lib::Payload".to_string(),
original_rust_path: String::new(),
variants: vec![
EnumVariant {
name: "Text".to_string(),
fields: vec![make_field("inner", TypeRef::String, false)],
doc: "UTF-8 encoded text payload.".to_string(),
is_default: false,
serde_rename: None,
is_tuple: true,
},
EnumVariant {
name: "Binary".to_string(),
fields: vec![make_field("inner", TypeRef::String, false)],
doc: "Base64-encoded binary payload.".to_string(),
is_default: false,
serde_rename: None,
is_tuple: true,
},
],
doc: "Wire payload variants.".to_string(),
cfg: None,
is_copy: false,
has_serde: false,
serde_tag: None,
serde_untagged: false,
serde_rename_all: None,
binding_excluded: false,
binding_exclusion_reason: None,
}],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
};
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
content.contains("#' Wire payload variants"),
"enum title from Rust doc must be emitted:\n{content}"
);
assert!(
content.contains("#' @field Text UTF-8 encoded text payload."),
"@field per variant must carry the variant's doc:\n{content}"
);
assert!(
content.contains("#' @field Binary Base64-encoded binary payload."),
"every variant must produce a `@field` line:\n{content}"
);
assert!(
content.contains("Payload <- new.env(parent = emptyenv())"),
"enum class env must still be emitted:\n{content}"
);
}
fn trait_bridge_config_for_tests() -> ResolvedCrateConfig {
resolved_one(
r#"
[workspace]
languages = ["r"]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
[crates.r]
package_name = "testlib"
[[crates.trait_bridges]]
trait_name = "OcrBackend"
super_trait = "test_lib::Plugin"
registry_getter = "test_lib::get_ocr_backend_registry"
register_fn = "register_ocr_backend"
unregister_fn = "unregister_ocr_backend"
clear_fn = "clear_ocr_backends"
"#,
)
}
#[test]
fn extendr_module_registers_trait_bridge_register_unregister_clear() {
let backend = ExtendrBackend;
let config = trait_bridge_config_for_tests();
let api = make_api_surface();
let files = backend.generate_bindings(&api, &config).unwrap();
let lib_rs = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("lib.rs"))
.expect("lib.rs must be generated");
for sym in ["register_ocr_backend", "unregister_ocr_backend", "clear_ocr_backends"] {
assert!(
lib_rs.content.contains(&format!("fn {sym};")),
"extendr_module! must register `{sym}`:\n{}",
lib_rs.content
);
}
}
#[test]
fn extendr_wrappers_emits_trait_bridge_register_unregister_clear() {
let backend = ExtendrBackend;
let config = trait_bridge_config_for_tests();
let api = make_api_surface();
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
content.contains("register_ocr_backend <- function(r_backend) .Call(\"wrap__register_ocr_backend\""),
"register wrapper must accept an R object and call wrap__register_ocr_backend:\n{content}"
);
assert!(
content.contains("unregister_ocr_backend <- function(name) .Call(\"wrap__unregister_ocr_backend\""),
"unregister wrapper must accept a name and call wrap__unregister_ocr_backend:\n{content}"
);
assert!(
content.contains("clear_ocr_backends <- function() .Call(\"wrap__clear_ocr_backends\""),
"clear wrapper must take no arguments:\n{content}"
);
}
#[test]
fn namespace_exports_trait_bridge_register_unregister_clear() {
let backend = ExtendrBackend;
let config = trait_bridge_config_for_tests();
let api = make_api_surface();
let files = backend.generate_public_api(&api, &config).unwrap();
let namespace = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("NAMESPACE"))
.expect("NAMESPACE must be generated");
for sym in ["register_ocr_backend", "unregister_ocr_backend", "clear_ocr_backends"] {
assert!(
namespace.content.contains(&format!("export({sym})")),
"NAMESPACE must export `{sym}`:\n{}",
namespace.content
);
}
}
#[test]
fn extendr_excludes_trait_bridge_functions_when_language_excluded() {
let config = resolved_one(
r#"
[workspace]
languages = ["r"]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
[crates.r]
package_name = "testlib"
[[crates.trait_bridges]]
trait_name = "OcrBackend"
super_trait = "test_lib::Plugin"
registry_getter = "test_lib::get_ocr_backend_registry"
register_fn = "register_ocr_backend"
unregister_fn = "unregister_ocr_backend"
clear_fn = "clear_ocr_backends"
exclude_languages = ["r"]
"#,
);
let collected = super::collect_trait_bridge_functions(&config);
assert!(
collected.is_empty(),
"no trait-bridge entries should be collected when r is excluded: {:?}",
collected.iter().map(|t| &t.name).collect::<Vec<_>>()
);
}
#[test]
fn regression_namespace_exports_functions_types_enums() {
let backend = ExtendrBackend;
let config = make_config();
let mut api = make_api_surface();
api.types.push(TypeDef {
name: "DocumentMetadata".to_string(),
rust_path: "test_lib::DocumentMetadata".to_string(),
original_rust_path: String::new(),
fields: vec![make_field("title", TypeRef::String, true)],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
binding_excluded: false,
binding_exclusion_reason: None,
});
api.enums.push(EnumDef {
name: "ConversionResult".to_string(),
rust_path: "test_lib::ConversionResult".to_string(),
original_rust_path: String::new(),
variants: vec![
EnumVariant {
name: "Ok".to_string(),
fields: vec![make_field("content", TypeRef::String, false)],
is_default: false,
serde_rename: None,
is_tuple: true,
doc: String::new(),
},
EnumVariant {
name: "Err".to_string(),
fields: vec![make_field("msg", TypeRef::String, false)],
is_default: false,
serde_rename: None,
is_tuple: true,
doc: String::new(),
},
],
doc: String::new(),
cfg: None,
is_copy: false,
has_serde: false,
serde_tag: None,
serde_untagged: false,
serde_rename_all: None,
binding_excluded: false,
binding_exclusion_reason: None,
});
let files = backend.generate_public_api(&api, &config).unwrap();
let namespace = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("NAMESPACE"))
.expect("NAMESPACE must be generated");
let content = &namespace.content;
assert!(
content.contains("useDynLib(testlib, .registration = TRUE)"),
"NAMESPACE must have useDynLib: {content}"
);
assert!(
content.contains("export(process)"),
"NAMESPACE must export free functions, got: {content}"
);
assert!(
content.contains("export(Config)"),
"NAMESPACE must export types like Config: {content}"
);
assert!(
content.contains("export(DocumentMetadata)"),
"NAMESPACE must export DocumentMetadata: {content}"
);
assert!(
content.contains("export(ConversionResult)"),
"NAMESPACE must export flat data enums: {content}"
);
let line_count = content.lines().count();
assert!(
line_count > 10,
"NAMESPACE should have many more than 10 lines, got {line_count}: {content}"
);
}
#[test]
fn r_field_long_descriptions_are_truncated_to_fit_120_char_lines() {
let backend = ExtendrBackend;
let config = make_config();
let long_doc = "Open Graph metadata (og:* properties) for social media Keys like \"title\", \"description\", \"image\", \"url\", etc.";
let api = ApiSurface {
crate_name: "test_lib".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "DocumentMetadata".to_string(),
rust_path: "test_lib::DocumentMetadata".to_string(),
original_rust_path: String::new(),
fields: vec![FieldDef {
doc: long_doc.to_string(),
..make_field("open_graph", TypeRef::String, true)
}],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: "Document metadata".to_string(),
cfg: None,
binding_excluded: false,
binding_exclusion_reason: None,
}],
functions: vec![],
enums: vec![],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
};
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
for line in content.lines() {
if line.contains("@field open_graph") {
assert!(
line.len() <= 120,
"@field line must be <= 120 chars, got {} chars: {}",
line.len(),
line
);
assert!(
line.contains("Open Graph metadata"),
"@field description was over-truncated: {}",
line
);
return;
}
}
panic!("Could not find @field open_graph line in:\n{}", content);
}
}