diff --git a/DESCRIPTION b/DESCRIPTION index 1f8ef22f..cf468883 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -69,6 +69,8 @@ Suggests: rcmdcheck, knitr, lintr, + mockery (>= 0.4.5), + patrick (>= 0.3.0), rmarkdown, testthat (>= 3.1.7), usethis diff --git a/NEWS.md b/NEWS.md index 698d04f0..384d24e9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -8,6 +8,10 @@ * Unknown macro options in dev and release now throw errors instead of warnings * `vendor_pkgs()` now has a `clean` argument to remove the `src/rust/vendor` directory after creating the `vendor.tar.xz` file. (#479) * `Makevars`(.win) now uses the `vendor/`, if it exists, before unzipping the tarball. (#479) +* Enhanced runtime compilation with `rust_source()` family of functions (#481) + * Dropped support for 32-bit Windows target + * Added support for ARM64 Windows target + # rextendr 0.4.2 diff --git a/R/source.R b/R/source.R index 2aa5ca5d..66487e9e 100644 --- a/R/source.R +++ b/R/source.R @@ -278,65 +278,7 @@ rust_source <- function( # append rtools path to the end of PATH on Windows if (opts[["use_rtools"]] && .Platform$OS.type == "windows") { - if (!suppressMessages(pkgbuild::has_rtools())) { - cli::cli_abort( - c( - "Unable to find Rtools that are needed for compilation.", - "i" = "Required version is {.emph {pkgbuild::rtools_needed()}}." - ), - class = "rextendr_error" - ) - } - - if (identical(R.version$crt, "ucrt")) { - # TODO: update this when R 5.0 is released. - if (!identical(R.version$major, "4")) { - cli::cli_abort( - "rextendr currently supports R 4.2", - call = rlang::caller_call(), - class = "rextendr_error" - ) - } - - minor_patch <- package_version(R.version$minor) - - if (minor_patch >= "5.0") { - rtools_version <- "45" # nolint: object_usage_linter - } else if (minor_patch >= "4.0") { - rtools_version <- "44" # nolint: object_usage_linter - } else if (minor_patch >= "3.0") { - rtools_version <- "43" # nolint: object_usage_linter - } else { - rtools_version <- "42" # nolint: object_usage_linter - } - - rtools_home <- normalizePath( - Sys.getenv( - glue("RTOOLS{rtools_version}_HOME"), - glue("C:\\rtools{rtools_version}") - ), - mustWork = TRUE - ) - - # c.f. https://github.com/wch/r-source/blob/f09d3d7fa4af446ad59a375d914a0daf3ffc4372/src/library/profile/Rprofile.windows#L70-L71 # nolint: line_length_linter - subdir <- c("x86_64-w64-mingw32.static.posix", "usr") - } else { - # rtools_path() returns path to the RTOOLS40_HOME\usr\bin, - # but we need RTOOLS40_HOME\mingw{arch}\bin, hence the "../.." - rtools_home <- normalizePath( - # `pkgbuild` may return two paths for R < 4.2 with Rtools40v2 - file.path(pkgbuild::rtools_path()[1], "..", ".."), - winslash = "/", - mustWork = TRUE - ) - - subdir <- paste0("mingw", ifelse(R.version$arch == "i386", "32", "64")) - # if RTOOLS40_HOME is properly set, this will have no real effect - withr::local_envvar(RTOOLS40_HOME = rtools_home) - } - - rtools_bin_path <- normalizePath(file.path(rtools_home, subdir, "bin")) - withr::local_path(rtools_bin_path, action = "suffix") + use_rtools() } # get target name, not null for Windows @@ -804,6 +746,10 @@ get_specific_target_name <- function() { return("i686-pc-windows-gnu") } + if (R.version$arch == "aarch64") { + return(NULL) + } + cli::cli_abort( "Unknown Windows architecture", class = "rextendr_error" diff --git a/R/use_rtools.R b/R/use_rtools.R new file mode 100644 index 00000000..d7892fe5 --- /dev/null +++ b/R/use_rtools.R @@ -0,0 +1,124 @@ +get_r_version <- function() { + R.version +} + +is_windows_arm <- function() { + proc_arch <- Sys.getenv("PROCESSOR_ARCHITECTURE") + r_arch <- get_r_version()[["arch"]] + + if (identical(proc_arch, "ARM64") && !identical(r_arch, "aarch64")) { + cli::cli_abort( + c( + "Architecture mismatch detected.", + "i" = "You are running the {.code {proc_arch}} version of Windows, but the {.code {r_arch}} version of R.", + "i" = "You can find ARM64 build of R at {.url https://www.r-project.org/nosvn/winutf8/aarch64}" + ), + class = "rextendr_error" + ) + } + + identical(proc_arch, "ARM64") && identical(r_arch, "aarch64") +} + +throw_if_no_rtools <- function() { + if (!suppressMessages(pkgbuild::has_rtools())) { + cli::cli_abort( + c( + "Unable to find Rtools that are needed for compilation.", + "i" = "Required version is {.emph {pkgbuild::rtools_needed()}}." + ), + class = "rextendr_error" + ) + } +} + +throw_if_not_ucrt <- function() { + if (!identical(get_r_version()[["crt"]], "ucrt")) { + cli::cli_abort( + c( + "R must be built with UCRT to use rextendr.", + "i" = "Please install the UCRT version of R from {.url https://cran.r-project.org/}." + ), + class = "rextendr_error" + ) + } +} + +get_rtools_version <- function() { + minor_patch <- package_version(get_r_version()[["minor"]]) + + if (minor_patch >= "5.0") { + "45" + } else if (minor_patch >= "4.0") { + "44" + } else if (minor_patch >= "3.0") { + "43" + } else { + "42" + } +} + +get_path_to_cargo_folder_arm <- function(rtools_root) { + path_to_cargo_folder <- file.path(rtools_root, "clangarm64", "bin") + path_to_cargo <- file.path(path_to_cargo_folder, "cargo.exe") + if (!file.exists(path_to_cargo)) { + cli::cli_abort( + c( + "{.code rextendr} on ARM Windows requires an ARM-compatible Rust toolchain.", + "i" = "Check this instructions to set up {.code cargo} using ARM version of RTools: {.url https://github.com/r-rust/faq?tab=readme-ov-file#does-rust-support-windows-on-arm64-aarch64}." # nolint: line_length_linter + ), + class = "rextendr_error" + ) + } + + normalizePath(path_to_cargo_folder, mustWork = TRUE) +} + +get_rtools_home <- function(rtools_version, is_arm) { + env_var <- if (is_arm) { + sprintf("RTOOLS%s_AARCH64_HOME", rtools_version) + } else { + sprintf("RTOOLS%s_HOME", rtools_version) + } + + default_path <- if (is_arm) { + sprintf("C:\\rtools%s-aarch64", rtools_version) + } else { + sprintf("C:\\rtools%s", rtools_version) + } + + normalizePath( + Sys.getenv(env_var, default_path), + mustWork = TRUE + ) +} + +get_rtools_bin_path <- function(rtools_home, is_arm) { + # c.f. https://github.com/wch/r-source/blob/f09d3d7fa4af446ad59a375d914a0daf3ffc4372/src/library/profile/Rprofile.windows#L70-L71 # nolint: line_length_linter + subdir <- if (is_arm) { + c("aarch64-w64-mingw32.static.posix", "usr") + } else { + c("x86_64-w64-mingw32.static.posix", "usr") + } + + normalizePath(file.path(rtools_home, subdir, "bin"), mustWork = TRUE) +} + +use_rtools <- function(.local_envir = parent.frame()) { + throw_if_no_rtools() + throw_if_not_ucrt() + + is_arm <- is_windows_arm() + rtools_version <- get_rtools_version() + rtools_home <- get_rtools_home(rtools_version, is_arm) + rtools_bin_path <- get_rtools_bin_path(rtools_home, is_arm) + + withr::local_path(rtools_bin_path, action = "suffix", .local_envir = .local_envir) + + if (is_arm) { + cargo_path <- get_path_to_cargo_folder_arm(rtools_home) + withr::local_path(cargo_path, .local_envir = .local_envir) + } + + invisible() +} diff --git a/tests/testthat/test-use_rtools.R b/tests/testthat/test-use_rtools.R new file mode 100644 index 00000000..81772e44 --- /dev/null +++ b/tests/testthat/test-use_rtools.R @@ -0,0 +1,262 @@ +patrick::with_parameters_test_that("is_windows_arm: ", + { + getenv_mock <- mockery::mock(proc_arch) + abort_spy <- mockery::mock() + + mockery::stub(is_windows_arm, "Sys.getenv", getenv_mock) + mockery::stub(is_windows_arm, "cli::cli_abort", abort_spy) + mockery::stub(is_windows_arm, "get_r_version", list(arch = r_arch)) + + result <- is_windows_arm() + + expect_equal(result, is_arm) + + mockery::expect_called(getenv_mock, 1) + mockery::expect_args(getenv_mock, 1, "PROCESSOR_ARCHITECTURE") + + mockery::expect_called(abort_spy, 0) + }, + is_arm = c(TRUE, FALSE), + proc_arch = c("ARM64", "AMD64"), + r_arch = c("aarch64", "x86_64"), + .test_name = "when proc_arch is {proc_arch} and r_arch is {r_arch}, returns {is_arm}" +) + +test_that("is_windows_arm throws on Windows ARM64 with R not aarch64", { + getenv_mock <- mockery::mock("ARM64") + abort_mock <- mockery::mock(stop("Aborted in test")) + + mockery::stub(is_windows_arm, "Sys.getenv", getenv_mock) + mockery::stub(is_windows_arm, "cli::cli_abort", abort_mock) + mockery::stub(is_windows_arm, "get_r_version", list(arch = "x64")) + + expect_error(is_windows_arm(), "Aborted in test") + + abort_mock_args <- mockery::mock_args(abort_mock)[[1]] + expect_equal(abort_mock_args[[1]][[1]], "Architecture mismatch detected.") + expect_equal(abort_mock_args[["class"]], "rextendr_error") +}) + +test_that("throw_if_no_rtools throws when Rtools is not found", { + abort_mock <- mockery::mock(stop("Aborted in test")) + + mockery::stub(throw_if_no_rtools, "pkgbuild::has_rtools", FALSE) + mockery::stub(throw_if_no_rtools, "cli::cli_abort", abort_mock) + + expect_error(throw_if_no_rtools(), "Aborted in test") + + abort_mock_args <- mockery::mock_args(abort_mock)[[1]] + expect_equal(abort_mock_args[[1]][[1]], "Unable to find Rtools that are needed for compilation.") + expect_equal(abort_mock_args[["class"]], "rextendr_error") +}) + +test_that("throw_if_no_rtools does not throw when Rtools is found", { + has_rtools_mock <- mockery::mock(TRUE) + abort_mock <- mockery::mock(stop("Aborted in test")) + + mockery::stub(throw_if_no_rtools, "pkgbuild::has_rtools", TRUE) + mockery::stub(throw_if_no_rtools, "cli::cli_abort", abort_mock) + + expect_silent(throw_if_no_rtools()) + + mockery::expect_called(abort_mock, 0) +}) + +test_that("throw_if_not_ucrt throws when R is not UCRT", { + abort_mock <- mockery::mock(stop("Aborted in test")) + + mockery::stub(throw_if_not_ucrt, "get_r_version", list(crt = "non-ucrt")) + mockery::stub(throw_if_not_ucrt, "cli::cli_abort", abort_mock) + + expect_error(throw_if_not_ucrt(), "Aborted in test") + + abort_mock_args <- mockery::mock_args(abort_mock)[[1]] + expect_equal(abort_mock_args[[1]][[1]], "R must be built with UCRT to use rextendr.") + expect_equal(abort_mock_args[["class"]], "rextendr_error") +}) + +test_that("throw_if_not_ucrt does not throw when R is UCRT", { + abort_mock <- mockery::mock(stop("Aborted in test")) + + mockery::stub(throw_if_not_ucrt, "get_r_version", list(crt = "ucrt")) + mockery::stub(throw_if_not_ucrt, "cli::cli_abort", abort_mock) + + expect_silent(throw_if_not_ucrt()) + + mockery::expect_called(abort_mock, 0) +}) + +patrick::with_parameters_test_that("get_rtools_version:", + { + mockery::stub(get_rtools_version, "get_r_version", list(minor = minor_version)) + + result <- get_rtools_version() + + expect_equal(result, expected_rtools_version) + }, + minor_version = c("5.1", "5.0", "4.3", "4.2", "4.1", "4.0", "3.3", "2.3"), + expected_rtools_version = c("45", "45", "44", "44", "44", "44", "43", "42"), + .test_name = "when R minor version is {minor_version}, returns {expected_rtools_version}" +) + +test_that("get_path_to_cargo_folder_arm constructs correct path when folder exists", { + path_to_cargo_folder_stub <- "path/to/cargo/folder" + path_to_cargo_stub <- "path/to/cargo" + normalized_path <- "normalized/path" + + abort_spy <- mockery::mock() + file_path_mock <- mockery::mock(path_to_cargo_folder_stub, path_to_cargo_stub) + normalize_path_mock <- mockery::mock(normalized_path) + file_exists_mock <- mockery::mock(TRUE) + + mockery::stub(get_path_to_cargo_folder_arm, "file.path", file_path_mock) + mockery::stub(get_path_to_cargo_folder_arm, "file.exists", file_exists_mock) + mockery::stub(get_path_to_cargo_folder_arm, "cli::cli_abort", abort_spy) + mockery::stub(get_path_to_cargo_folder_arm, "normalizePath", normalize_path_mock) + + result <- get_path_to_cargo_folder_arm("rtools/root") + + expect_equal(result, normalized_path) + mockery::expect_args(file_path_mock, 1, "rtools/root", "clangarm64", "bin") + mockery::expect_args(file_path_mock, 2, path_to_cargo_folder_stub, "cargo.exe") + mockery::expect_args(file_exists_mock, 1, path_to_cargo_stub) + mockery::expect_args(normalize_path_mock, 1, path_to_cargo_folder_stub, mustWork = TRUE) + mockery::expect_called(abort_spy, 0) +}) + +test_that("get_path_to_cargo_folder_arm throws when cargo.exe does not exist", { + path_to_cargo_folder_stub <- "path/to/cargo/folder" + path_to_cargo_stub <- "path/to/cargo" + + abort_mock <- mockery::mock(stop("Aborted in test")) + file_path_mock <- mockery::mock(path_to_cargo_folder_stub, path_to_cargo_stub) + file_exists_mock <- mockery::mock(FALSE) + + mockery::stub(get_path_to_cargo_folder_arm, "file.path", file_path_mock) + mockery::stub(get_path_to_cargo_folder_arm, "file.exists", file_exists_mock) + mockery::stub(get_path_to_cargo_folder_arm, "cli::cli_abort", abort_mock) + + expect_error(get_path_to_cargo_folder_arm("rtools/root"), "Aborted in test") + + mockery::expect_args(file_path_mock, 1, "rtools/root", "clangarm64", "bin") + mockery::expect_args(file_path_mock, 2, path_to_cargo_folder_stub, "cargo.exe") + mockery::expect_args(file_exists_mock, 1, path_to_cargo_stub) + + abort_mock_args <- mockery::mock_args(abort_mock)[[1]] + expect_equal(abort_mock_args[[1]][[1]], "{.code rextendr} on ARM Windows requires an ARM-compatible Rust toolchain.") + expect_equal(abort_mock_args[["class"]], "rextendr_error") +}) + +patrick::with_parameters_test_that("get_rtools_home:", + { + env_var <- "env_var" + default_path <- "default_path" + get_env_result <- "get_env_result" + normalize_path_result <- "normalize_path_result" + + sprintf_mock <- mockery::mock(env_var, default_path) + getenv_mock <- mockery::mock(get_env_result) + normalize_path_mock <- mockery::mock(normalize_path_result) + + mockery::stub(get_rtools_home, "sprintf", sprintf_mock) + mockery::stub(get_rtools_home, "Sys.getenv", getenv_mock) + mockery::stub(get_rtools_home, "normalizePath", normalize_path_mock) + + rtools_version <- "rtools_version" + + result <- get_rtools_home(rtools_version, is_arm) + + expect_equal(result, normalize_path_result) + mockery::expect_args(sprintf_mock, 1, rtools_env_var_template, rtools_version) + mockery::expect_args(sprintf_mock, 2, rtools_default_path_template, rtools_version) + mockery::expect_args(getenv_mock, 1, env_var, default_path) + mockery::expect_args(normalize_path_mock, 1, get_env_result, mustWork = TRUE) + }, + is_arm = c(TRUE, FALSE), + rtools_env_var_template = c("RTOOLS%s_AARCH64_HOME", "RTOOLS%s_HOME"), + rtools_default_path_template = c("C:\\rtools%s-aarch64", "C:\\rtools%s"), + .test_name = "when is_arm is {is_arm}, env var should be {rtools_env_var_template} and default path should start with {rtools_default_path_template}" # nolint: line_length_linter +) + +patrick::with_parameters_test_that("get_rtools_bin_path:", + { + rtools_home <- "rtools_home" + file_path_result <- "file/path/result" + expected_path <- "normalized/path" + file_path_mock <- mockery::mock(file_path_result) + normalize_path_mock <- mockery::mock(expected_path) + + mockery::stub(get_rtools_bin_path, "file.path", file_path_mock) + mockery::stub(get_rtools_bin_path, "normalizePath", normalize_path_mock) + + result <- get_rtools_bin_path(rtools_home, is_arm) + + expect_equal(result, expected_path) + expected_arg <- c(subdir, "usr") + mockery::expect_args(file_path_mock, 1, rtools_home, expected_arg, "bin") + mockery::expect_args(normalize_path_mock, 1, file_path_result, mustWork = TRUE) + }, + is_arm = c(TRUE, FALSE), + subdir = c("aarch64-w64-mingw32.static.posix", "x86_64-w64-mingw32.static.posix"), + .test_name = "when is_arm is {is_arm}, subdir should start with {subdir}" +) + +test_that("use_rtools handled x86_64 architecture", { + rtools_version <- "rtools_version" + rtools_home <- "rtools_home" + rtools_bin_path <- "rtools_bin_path" + + withr_local_path_mock <- mockery::mock() + get_rtools_home_mock <- mockery::mock(rtools_home) + get_rtools_bin_path_mock <- mockery::mock(rtools_bin_path) + + mockery::stub(use_rtools, "throw_if_no_rtools", NULL) + mockery::stub(use_rtools, "throw_if_not_ucrt", NULL) + mockery::stub(use_rtools, "is_windows_arm", FALSE) + mockery::stub(use_rtools, "get_rtools_version", rtools_version) + + mockery::stub(use_rtools, "withr::local_path", withr_local_path_mock) + mockery::stub(use_rtools, "get_path_to_cargo_folder_arm", function(...) stop("Should not be called")) + mockery::stub(use_rtools, "get_rtools_home", get_rtools_home_mock) + mockery::stub(use_rtools, "get_rtools_bin_path", get_rtools_bin_path_mock) + + parent_env <- "parent_env" + + use_rtools(parent_env) + + mockery::expect_args(get_rtools_home_mock, 1, rtools_version, FALSE) + mockery::expect_args(get_rtools_bin_path_mock, 1, rtools_home, FALSE) + mockery::expect_args(withr_local_path_mock, 1, rtools_bin_path, action = "suffix", .local_envir = parent_env) +}) + +test_that("use_rtools handled aarch64 architecture", { + rtools_version <- "rtools_version" + rtools_home <- "rtools_home" + rtools_bin_path <- "rtools_bin_path" + cargo_path <- "cargo_path" + + withr_local_path_mock <- mockery::mock() + get_rtools_home_mock <- mockery::mock(rtools_home) + get_rtools_bin_path_mock <- mockery::mock(rtools_bin_path) # nolint: object_length_linter + get_path_to_cargo_folder_arm_mock <- mockery::mock(cargo_path) # nolint: object_length_linter + + mockery::stub(use_rtools, "throw_if_no_rtools", NULL) + mockery::stub(use_rtools, "throw_if_not_ucrt", NULL) + mockery::stub(use_rtools, "is_windows_arm", TRUE) + mockery::stub(use_rtools, "get_rtools_version", rtools_version) + + mockery::stub(use_rtools, "withr::local_path", withr_local_path_mock) + mockery::stub(use_rtools, "get_rtools_home", get_rtools_home_mock) + mockery::stub(use_rtools, "get_rtools_bin_path", get_rtools_bin_path_mock) + mockery::stub(use_rtools, "get_path_to_cargo_folder_arm", get_path_to_cargo_folder_arm_mock) + + parent_env <- "parent_env" + + use_rtools(parent_env) + + mockery::expect_args(get_rtools_home_mock, 1, rtools_version, TRUE) + mockery::expect_args(get_rtools_bin_path_mock, 1, rtools_home, TRUE) + mockery::expect_args(withr_local_path_mock, 1, rtools_bin_path, action = "suffix", .local_envir = parent_env) + mockery::expect_args(get_path_to_cargo_folder_arm_mock, 1, rtools_home) + mockery::expect_args(withr_local_path_mock, 2, cargo_path, .local_envir = parent_env) +})