use crate::core::backend::GeneratedFile;
use crate::core::config::{Language, ResolvedCrateConfig};
use crate::core::ir::ApiSurface;
use crate::core::template_versions as tv;
use crate::core::version::to_r_version;
use crate::{
scaffold::cargo_package_header, scaffold::core_dep_features, scaffold::detect_workspace_inheritance,
scaffold::scaffold_meta,
};
use std::path::PathBuf;
pub(crate) fn scaffold_r(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let meta = scaffold_meta(config);
// R / CRAN rejects SemVer dash-form prereleases; convert to the four-component form.
let version = to_r_version(&api.version);
let package_name = config.r_package_name();
let mut description = meta.description.clone();
if description.ends_with('.') {
description.pop();
}
let authors_r = if meta.authors.is_empty() {
anyhow::bail!("R scaffold requires package metadata authors; set package_metadata.authors or scaffold.authors");
} else if let Some((given, family, email)) = parse_r_author(meta.authors.first().unwrap_or(&String::new())) {
format!("Authors@R: person(\"{given}\", \"{family}\", email = \"{email}\", role = c(\"aut\", \"cre\"))")
} else {
format!(
"Authors@R: person(\"{}\", role = c(\"aut\", \"cre\"))",
meta.authors.first().unwrap_or(&"Author Name".to_string())
)
};
let repository_lines = meta
.configured_repository
.as_deref()
.map(|repository| format!("URL: {repository}\nBugReports: {repository}/issues"))
.unwrap_or_default();
let license = meta.license.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"R scaffold requires package metadata license; set package_metadata.license or scaffold.license"
)
})?;
let content = format!(
r#"Package: {package}
Title: {title}
Version: {version}
{authors}
Description: {description}
Rust bindings generated with extendr.
{repository_lines}
License: {license}
Depends: R (>= 4.2)
Imports: jsonlite
Suggests:
testthat (>= 3.0.0),
withr,
roxygen2,
lintr,
styler
SystemRequirements: Cargo (Rust's package manager), rustc (>= 1.91)
Config/rextendr/version: {rextendr}
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.3
Config/testthat/edition: 3
"#,
package = package_name,
title = meta.description,
version = version,
authors = authors_r,
description = description,
repository_lines = repository_lines,
license = license,
rextendr = tv::cran::REXTENDR,
);
Ok(vec![
GeneratedFile {
path: PathBuf::from("packages/r/DESCRIPTION"),
content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from("packages/r/.lintr"),
content: r#"linters: linters_with_defaults(
line_length_linter(120),
object_name_linter = NULL,
object_usage_linter = NULL,
commented_code_linter = NULL
)
exclusions: list(
"R/extendr-wrappers.R"
)
"#
.to_string(),
generated_header: false,
},
])
}
fn parse_r_author(author: &str) -> Option<(String, String, String)> {
let (name, email) = author.rsplit_once('<')?;
let email = email.strip_suffix('>')?.trim();
if email.is_empty() {
return None;
}
let name = name.trim();
let (given, family) = name.rsplit_once(' ')?;
if given.is_empty() || family.is_empty() {
return None;
}
Some((given.to_string(), family.to_string(), email.to_string()))
}
pub(crate) fn scaffold_r_cargo(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let meta = scaffold_meta(config);
let version = &api.version;
let core_crate_dir = config.core_crate_dir();
let ws = detect_workspace_inheritance(config.workspace_root.as_deref());
let pkg_header = cargo_package_header(&format!("{core_crate_dir}-r"), version, "2024", &meta, &ws);
// extendr requires staticlib (for R's dyn.load) + lib (for Rust tests).
// "cdylib" alone causes linker failures on macOS/Linux.
let has_async =
api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
// Trait-bridge impls emitted under any `[[crates.trait_bridges]]` carry
// `#[async_trait::async_trait]` on their methods — declare the crate here so
// the macro resolves at compile time (other backends like PHP, Ruby already
// declare it unconditionally for the same reason).
let has_trait_bridges = !config.trait_bridges.is_empty();
// Collect all [dependencies] entries then sort alphabetically so the emitted
// Cargo.toml is cargo-sort canonical without a post-processing step.
let features_str = core_dep_features(config, Language::R);
let mut dep_lines: Vec<String> = vec![
crate::scaffold::render_core_dep(
&config.name,
&format!("../../../../crates/{core_crate_dir}"),
&features_str,
version,
),
format!("extendr-api = \"{}\"", tv::cargo::EXTENDR_API),
"serde = { version = \"1\", features = [\"derive\"] }".to_owned(),
"serde_json = \"1\"".to_owned(),
];
if has_async {
dep_lines.push("tokio = { version = \"1\", features = [\"rt-multi-thread\"] }".to_owned());
}
if has_trait_bridges {
dep_lines.push("async-trait = \"0.1\"".to_owned());
}
dep_lines.sort();
let deps_section = dep_lines.join("\n");
let cargo_content = format!(
r#"{pkg_header}
[lib]
crate-type = ["staticlib", "lib"]
[dependencies]
{deps_section}
"#,
pkg_header = pkg_header,
deps_section = deps_section,
);
let r_package_name = config.r_package_name();
let lib_name = r_package_name.replace('-', "_");
// The Rust crate emits its staticlib as `lib{crate_name}.a`, where crate_name
// is `{core_crate_dir}-r` (with hyphens replaced by underscores by cargo).
// This is independent of the R package's user-facing name.
let rust_lib_name = format!("{}_r", core_crate_dir).replace('-', "_");
// Makevars — tells R CMD INSTALL how to build the staticlib and link it.
// Includes a binary-package target for CI: `make binary` produces {pkg_name}_{version}_{platform}.tgz
//
// Downstream consumers may inject extra link flags via
// `[crates.r] extra_makevars_prelude` and `extra_pkg_libs` — used when the
// staticlib transitively dlopens a system library (e.g. libheif) that R CMD
// INSTALL must also link against.
let prelude = config
.r
.as_ref()
.map(|r| r.extra_makevars_prelude.clone())
.unwrap_or_default();
let extra_libs = config.r.as_ref().map(|r| r.extra_pkg_libs.clone()).unwrap_or_default();
let prelude_block = if prelude.is_empty() {
String::new()
} else {
format!("{}\n", prelude.join("\n"))
};
let extra_libs_str = if extra_libs.is_empty() {
String::new()
} else {
format!(" {}", extra_libs.join(" "))
};
let makevars_content = format!(
"CARGO_BUILD_ARGS = --release\nSTATLIB = ./rust/target/release/lib{rust_lib_name}.a\n{prelude_block}PKG_LIBS = -L./rust/target/release -l{rust_lib_name}{extra_libs_str} $(LAPACK_LIBS) $(BLAS_LIBS) $(FLIBS)\n\nall: $(SHLIB)\n\n$(STATLIB):\n\tcargo build --manifest-path ./rust/Cargo.toml $(CARGO_BUILD_ARGS)\n\n$(SHLIB): $(STATLIB)\n\nbinary:\n\tcd .. && R CMD INSTALL --build .\n\nclean:\n\trm -f $(SHLIB) $(STATLIB)\n\tcargo clean --manifest-path ./rust/Cargo.toml\n",
rust_lib_name = rust_lib_name,
prelude_block = prelude_block,
extra_libs_str = extra_libs_str,
);
// Makevars.in — autoconf variant; same content.
let makevars_in_content = makevars_content.clone();
// Makevars.win.in — Windows variant; cargo produces a .lib, not .a.
// Includes a binary-package target for CI: `make binary` produces {pkg_name}_{version}_{platform}.tgz
let makevars_win_content = format!(
"CARGO_BUILD_ARGS = --release --target x86_64-pc-windows-gnu\nSTATLIB = ./rust/target/x86_64-pc-windows-gnu/release/{rust_lib_name}.lib\nPKG_LIBS = -L./rust/target/x86_64-pc-windows-gnu/release -l{rust_lib_name} -lws2_32 -ladvapi32 -luserenv -lbcrypt -lntdll\n\nall: $(SHLIB)\n\n$(STATLIB):\n\tcargo build --manifest-path ./rust/Cargo.toml $(CARGO_BUILD_ARGS)\n\n$(SHLIB): $(STATLIB)\n\nbinary:\n\tcd .. && R CMD INSTALL --build .\n\nclean:\n\trm -f $(SHLIB) $(STATLIB)\n\tcargo clean --manifest-path ./rust/Cargo.toml\n",
rust_lib_name = rust_lib_name,
);
// entrypoint.c — C shim required by extendr. R looks up `R_init_{pkg}` on
// package load. The extendr macro emits `R_init_{pkg}_extendr` (the trailing
// `_extendr` is hardcoded by the extendr-macros crate), so this shim
// forwards the call.
let entrypoint_c_content = format!(
"// Generated entrypoint: forwards to the extendr-generated init function.\n// Do not edit — regenerate with `alef generate`.\n#include <R_ext/Visibility.h>\n\nvoid R_init_{lib_name}_extendr(void *dll);\n\nvoid attribute_visible R_init_{lib_name}(void *dll) {{\n R_init_{lib_name}_extendr(dll);\n}}\n",
lib_name = lib_name,
);
// NAMESPACE is intentionally NOT scaffolded here. The extendr backend's
// `generate_public_api` owns it and emits the full directive set
// (`useDynLib` + every `export(...)` / `S3method(...)` entry). Scaffolding
// ran after generate, so a stub here would clobber the real NAMESPACE with
// a useDynLib-only file, leaving the package with no exported symbols.
Ok(vec![
GeneratedFile {
path: PathBuf::from("packages/r/src/rust/Cargo.toml"),
content: cargo_content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from("packages/r/src/Makevars"),
content: makevars_content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from("packages/r/src/Makevars.in"),
content: makevars_in_content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from("packages/r/src/Makevars.win.in"),
content: makevars_win_content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from("packages/r/src/entrypoint.c"),
content: entrypoint_c_content,
generated_header: false,
},
])
}