alef 0.23.33

Opinionated polyglot binding generator for Rust libraries
Documentation
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
    let makevars_content = format!(
        "CARGO_BUILD_ARGS = --release\nSTATLIB = ./rust/target/release/lib{rust_lib_name}.a\nPKG_LIBS = -L./rust/target/release -l{rust_lib_name} $(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,
    );

    // 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,
        },
    ])
}