diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index df027504c..276ed6a62 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -57,10 +57,6 @@ jobs: - name: cmdstan env vars run: | echo "CMDSTAN_PATH=${HOME}/.cmdstan" >> $GITHUB_ENV - # GHA Windows ARM containers configured with RTOOLS45_HOME instead of RTOOLS45_AARCH64_HOME - if [[ "${{ matrix.config.os }}" == "windows-11-arm" ]]; then - echo "RTOOLS45_AARCH64_HOME=C:/rtools45-aarch64" >> $GITHUB_ENV - fi shell: bash - uses: actions/checkout@v7 @@ -82,25 +78,38 @@ jobs: cache: "always" extra-packages: any::rcmdcheck, local::., ${{ matrix.config.r == '4.1' && 'qs2=?ignore' || '' }} - - name: Debug Windows toolchain resolution - if: ${{ runner.os == 'Windows' }} + - name: Test Windows toolchain in directory with spaces + if: ${{ runner.os == 'Windows' && matrix.config.r == '4.1' }} run: | - rtools_home <- cmdstanr:::rtools4x_home_path() - candidates <- cmdstanr:::rtools4x_toolchain_candidates() - selected <- cmdstanr:::rtools4x_toolchain_path() - cat("R version: ", as.character(getRversion()), "\n", sep = "") - cat("Rtools home: ", rtools_home, "\n", sep = "") - cat("Toolchain candidates:\n") - if (length(candidates) == 0) { - cat(" - \n") - } else { - cat(paste0(" - ", candidates), sep = "\n") - cat("\n") + New-Item -ItemType Directory -Path "C:\tmp path with spaces" -Force + Move-Item -Path "C:\rtools40" -Destination "C:\tmp path with spaces\" + echo "RTOOLS40_HOME=C:/tmp path with spaces/rtools40" >> $env:GITHUB_ENV + shell: pwsh + + - name: Verify R_TOOLS_SOFT configuration + if: ${{ matrix.config.os == 'windows-latest' && matrix.config.r != '4.1' }} + run: | + rtools_soft <- suppressWarnings( + tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE) + ) + if ( + !is.null(attr(rtools_soft, "status")) || + length(rtools_soft) != 1L || + !nzchar(trimws(rtools_soft)) + ) { + stop("R CMD config R_TOOLS_SOFT did not return a valid path.") } - cat("Selected toolchain: ", selected, "\n", sep = "") - selected_ok <- cmdstanr:::is_rtools4x_toolchain_usable(selected) - if (!selected_ok) { - stop("Resolved Windows toolchain path is not usable.") + rtools_soft <- trimws(rtools_soft) + required <- c( + file.path(dirname(rtools_soft), "usr", "bin", "make.exe"), + file.path(rtools_soft, "bin", "c++.exe") + ) + missing <- required[!file.exists(required)] + if (length(missing)) { + stop( + "R_TOOLS_SOFT is configured, but expected executables are missing: ", + paste(missing, collapse = ", ") + ) } shell: Rscript {0} diff --git a/DESCRIPTION b/DESCRIPTION index 8f6dc0989..0f6fb86f0 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: cmdstanr Title: R Interface to 'CmdStan' -Version: 0.9.0.9001 +Version: 0.9.0.9002 Date: 2025-03-30 Authors@R: c(person(given = "Jonah", family = "Gabry", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index b8d6d0d8c..55224222f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,6 +18,7 @@ variables are, instead of erroring. (#1225) which previously errored. (#1225) * The `CMDSTANR_NO_VER_CHECK` R option and environment variable are deprecated as of CmdStanR 1.0.0; use the lowercase `cmdstanr_no_ver_check` forms instead. +* `check_cmdstan_toolchain()` now locates Windows toolchains using `R_TOOLS_SOFT` and falls back to `PATH`, improving support for alternate R distributions and future Rtools releases. (#1211) * `$compile()` now works with named `stanc_options` values such as `canonicalize`. The values were shell-quoted for Make and the same quoted strings were also passed to `stanc` directly, which rejected them. (#1227) diff --git a/R/install.R b/R/install.R index b73347c3a..635560d55 100644 --- a/R/install.R +++ b/R/install.R @@ -616,54 +616,13 @@ check_wsl_toolchain <- function() { } check_rtools4x_windows_toolchain <- function(quiet = FALSE) { - rtools_path <- rtools4x_home_path() - rtools_version <- paste0("Rtools", rtools4x_version()) - # If RTOOLS4X_HOME is not set (the env. variable gets set on install) - # we assume that RTools 40 is not installed. - if (!nzchar(rtools_path)) { + toolchain_path <- toolchain_PATH_env_var() + if (is.null(toolchain_path)) { stop( - "\n", rtools_version, " was not found but is required to run CmdStan with R version ", - R.version$major, ".", R.version$minor, ".", - "\nPlease install or reinstall the appropriate Rtools version for this R installation,", - "\nrestart R, and then run cmdstanr::check_cmdstan_toolchain().", - call. = FALSE - ) - } - # If RTools is installed in a path with spaces or brackets - # we error as this path is not valid - if (grepl("\\(|)| ", rtools_path)) { - stop( - "\n", rtools_version, " is installed in a path with spaces or brackets, which is not supported.", - "\nPlease reinstall the appropriate Rtools version for this R installation to a valid path,", - "\nrestart R, and then run cmdstanr::check_cmdstan_toolchain().", - call. = FALSE - ) - } - usr_bin <- repair_path(file.path(rtools_path, "usr", "bin")) - # Fail early with a clear message if the base make tool is missing - make_found <- any(file.exists(file.path(usr_bin, c("make.exe", "mingw32-make.exe")))) - if (!make_found) { - stop( - "\n", rtools_version, " is missing the required 'make' executable in ", usr_bin, ".", - "\nPlease reinstall the appropriate Rtools version for this R installation,", - "\nrestart R, and then run cmdstanr::check_cmdstan_toolchain().", - call. = FALSE - ) - } - candidates <- rtools4x_toolchain_candidates() - # Validate candidate toolchains here so build errors later are not opaque - has_usable_toolchain <- any(vapply(candidates, is_rtools4x_toolchain_usable, logical(1))) - if (!has_usable_toolchain) { - if (length(candidates) == 0) { - candidates_message <- "\n- " - } else { - candidates_message <- paste0("\n- ", paste(candidates, collapse = "\n- ")) - } - stop( - "\n", rtools_version, " does not contain a supported C++ toolchain.", - "\nChecked the following paths:", - candidates_message, - "\nPlease reinstall the appropriate Rtools version for this R installation,", + "CmdStanR could not find both make and a C++ compiler in R's ", + "configured toolchain or on PATH.", + "\nPlease install or reinstall the appropriate Rtools version for this ", + "R installation, or add a compatible toolchain to PATH,", "\nrestart R, and then run cmdstanr::check_cmdstan_toolchain().", call. = FALSE ) @@ -749,108 +708,71 @@ cmdstan_arch_suffix <- function(version = NULL) { paste0("-linux-", selected_arch) } -toolchain_PATH_env_var <- function() { - if (!os_is_windows()) { - return(NULL) - } - rtools_home <- rtools4x_home_path() - if (!nzchar(rtools_home)) { - return(NULL) - } - paste0( - repair_path(file.path(rtools_home, "usr", "bin")), ";", - rtools4x_toolchain_path() - ) -} +# Thin wrapper around `tools::Rcmd()` to allow mocking +.cmdstanr_rcmd <- function(...) tools::Rcmd(...) -#' Ordered candidate RTools toolchain bin paths -#' -#' On x86_64, candidate order is ABI-aware so legacy fallback paths are tried -#' in an order compatible with the current R toolchain. -#' -#' @noRd -#' @return A character vector of normalized candidate toolchain bin paths -rtools4x_toolchain_candidates <- function() { - rtools_home <- rtools4x_home_path() - if (!nzchar(rtools_home)) { - return(character()) - } - # Prefer the modern static toolchain first, then ABI-compatible legacy - # fallbacks for older Rtools layouts - toolchains <- if (arch_is_aarch64()) { - "aarch64-w64-mingw32.static.posix" - } else if (is_ucrt_toolchain()) { - c("x86_64-w64-mingw32.static.posix", "ucrt64", "mingw64") +toolchain_PATH_env_var <- function() { + # Return a previously successful lookup if available + # For non-windows systems the initialized path stays NULL + if (!is.null(.cmdstanr$TOOLCHAIN_PATH) || !os_is_windows()) { + return(.cmdstanr$TOOLCHAIN_PATH) + } + + # Lookup the configured toolchain location for the installation + # This variable is set at installation since R 4.2 + # e.g., 'C:/rtools45/x86_64-w64-mingw32.static.posix' + # R 4.0 and R 4.1 did not set the R_TOOLS_SOFT config variable, so + # we use the RTOOLS40_HOME environment variable instead + if (current_r_version() < "4.2.0") { + rtools40_home <- Sys.getenv("RTOOLS40_HOME", "C:\\rtools40") + r_arch <- ifelse(Sys.getenv("R_ARCH") == "/i386", "mingw32", "mingw64") + rtools_soft <- file.path(rtools40_home, r_arch) } else { - c("x86_64-w64-mingw32.static.posix", "mingw64", "ucrt64") + rtools_soft <- tryCatch( + suppressWarnings( + .cmdstanr_rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE) + ), + error = function(e) "" + ) + if (!is.null(attr(rtools_soft, "status")) || length(rtools_soft) != 1L) { + rtools_soft <- "" + } else { + rtools_soft <- trimws(rtools_soft) + } } - repair_path(file.path(rtools_home, toolchains, "bin")) -} -# A candidate is usable if the directory exists and contains a g++ executable -is_rtools4x_toolchain_usable <- function(path) { - if (!nzchar(path) || !dir.exists(path)) { - return(FALSE) - } - any(file.exists(file.path(path, c("g++.exe", "g++")))) -} + rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") + rtools_cpp_dir <- file.path(rtools_soft, "bin") -#' Resolve the preferred RTools toolchain bin path -#' -#' Returns the first usable path from `rtools4x_toolchain_candidates()`. If no -#' candidate is usable, returns the first candidate for deterministic diagnostics. -#' -#' @noRd -#' @return A single path string, or `""` if no candidates are available. -rtools4x_toolchain_path <- function() { - candidates <- rtools4x_toolchain_candidates() - if (length(candidates) == 0) { - return("") + # R 4.2+ prepends the toolchain directory to PATH, so it will be found first + if (!nzchar(rtools_soft) || + !file.exists(file.path(rtools_bin_dir, "make.exe"))) { + make_path <- Sys.which("make") + rtools_bin_dir <- ifelse(nzchar(make_path), dirname(make_path), "") } - # Return the first usable candidate (ordered by preference above). - usable <- vapply(candidates, is_rtools4x_toolchain_usable, logical(1)) - if (any(usable)) { - return(candidates[which(usable)[1]]) + if (!nzchar(rtools_soft) || + !file.exists(file.path(rtools_cpp_dir, "c++.exe"))) { + cpp_path <- Sys.which("c++") + rtools_cpp_dir <- ifelse(nzchar(cpp_path), dirname(cpp_path), "") } - candidates[1] -} - -rtools4x_version <- function() { - rtools_ver <- NULL - r_version <- current_r_version() - - if (r_version < "4.2.0") { - rtools_ver <- "40" - } else if (r_version < "4.3.0") { - rtools_ver <- "42" - } else if (r_version < "4.4.0") { - rtools_ver <- "43" - } else if (r_version < "4.5.0") { - rtools_ver <- "44" - } else { - rtools_ver <- "45" - } - rtools_ver -} - -rtools4x_home_path <- function() { - rtools_ver <- rtools4x_version() - if (arch_is_aarch64()) { - rtools_ver <- paste0(rtools_ver, "_AARCH64") - } - path <- Sys.getenv(paste0("RTOOLS", rtools_ver, "_HOME")) - if (!nzchar(path)) { - default_path <- repair_path(file.path(paste0("C:/rtools", rtools_ver))) - if (arch_is_aarch64()) { - default_path <- paste0(default_path, "-aarch64") - } - if (dir.exists(default_path)) { - path <- default_path + if (rtools_bin_dir != "" && rtools_cpp_dir != "") { + toolchain_dirs <- unique( + repair_path(short_path(c(rtools_bin_dir, rtools_cpp_dir))) + ) + if (any(grepl("[() ]", toolchain_dirs))) { + stop( + "The Windows toolchain path contains spaces or parentheses, and ", + "CmdStanR could not convert it to a usable short path. Please install ", + "or move the toolchain to a path without spaces or parentheses, ", + "restart R, and then run cmdstanr::check_cmdstan_toolchain().", + call. = FALSE + ) } + .cmdstanr$TOOLCHAIN_PATH <- paste(toolchain_dirs, collapse = ";") } - path + .cmdstanr$TOOLCHAIN_PATH } assert_supported_requested_cmdstan_version <- function(version, source = "version") { diff --git a/R/path.R b/R/path.R index 0874b960d..7f7c859d6 100644 --- a/R/path.R +++ b/R/path.R @@ -122,6 +122,7 @@ cmdstan_version <- function(error_on_NA = TRUE) { .cmdstanr$VERSION <- NULL .cmdstanr$TEMP_DIR <- NULL .cmdstanr$WSL <- FALSE +.cmdstanr$TOOLCHAIN_PATH <- NULL unset_cmdstan_path <- function() { .cmdstanr$PATH <- NULL diff --git a/R/utils.R b/R/utils.R index eed238bc3..56972d027 100644 --- a/R/utils.R +++ b/R/utils.R @@ -89,10 +89,6 @@ is_rosetta2 <- function() { rosetta2 } -arch_is_aarch64 <- function() { - isTRUE(R.version$arch == "aarch64") -} - # Returns the type of make command to use to compile depending on the OS # First checks if $MAKE is set, otherwise falls back to "make" make_cmd <- function() { diff --git a/tests/testthat/_snaps/install.md b/tests/testthat/_snaps/install.md index d7dfaedc3..1297ba0d1 100644 --- a/tests/testthat/_snaps/install.md +++ b/tests/testthat/_snaps/install.md @@ -6,3 +6,21 @@ Warning: The 'fix' argument is deprecated as of CmdStanR 1.0.0 and will be removed in a future release. +# toolchain_PATH_env_var() rejects unsafe toolchain paths + + Code + toolchain_PATH_env_var() + Condition + Error: + ! The Windows toolchain path contains spaces or parentheses, and CmdStanR could not convert it to a usable short path. Please install or move the toolchain to a path without spaces or parentheses, restart R, and then run cmdstanr::check_cmdstan_toolchain(). + +# check_rtools4x_windows_toolchain() stops when no toolchain found + + Code + check_rtools4x_windows_toolchain() + Condition + Error: + ! CmdStanR could not find both make and a C++ compiler in R's configured toolchain or on PATH. + Please install or reinstall the appropriate Rtools version for this R installation, or add a compatible toolchain to PATH, + restart R, and then run cmdstanr::check_cmdstan_toolchain(). + diff --git a/tests/testthat/test-install.R b/tests/testthat/test-install.R index ec4a7bca4..4babdfd9d 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -304,217 +304,504 @@ test_that("deprecated CMDSTANR_USE_MSYS_TOOLCHAIN is ignored with warning", { }) }) -test_that("Rtools helpers compare R versions numerically", { +test_that("check_cmdstan_toolchain(fix = TRUE) is deprecated", { + expect_snapshot( + check_cmdstan_toolchain(fix = TRUE, quiet = TRUE) + ) +}) + +# Windows toolchain discovery tests ---------------------------------------- + +test_that("toolchain_PATH_env_var() returns NULL on non-Windows", { + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + .cmdstanr$TOOLCHAIN_PATH <- NULL + local_mocked_bindings(os_is_windows = function() FALSE) + expect_null(toolchain_PATH_env_var()) +}) + +test_that("toolchain_PATH_env_var() caches result after first call", { + skip_if(!os_is_windows()) + + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + .cmdstanr$TOOLCHAIN_PATH <- NULL + + # First call should populate the cache + first_result <- toolchain_PATH_env_var() + expect_identical(.cmdstanr$TOOLCHAIN_PATH, first_result) + + # Second call should return cached value without re-running lookup + second_result <- toolchain_PATH_env_var() + expect_identical(second_result, first_result) +}) + +test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { + skip_if(!os_is_windows()) + + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_home <- utils::shortPathName(withr::local_tempdir(pattern = "rtools40-home-")) + fake_cpp_dir <- file.path(fake_home, "mingw64", "bin") + fake_bin_dir <- file.path(fake_home, "usr", "bin") + + # Create the expected directory structure for R 4.0/4.1 + dir.create(fake_cpp_dir, recursive = TRUE, showWarnings = FALSE) + dir.create(fake_bin_dir, recursive = TRUE, showWarnings = FALSE) + file.create(file.path(fake_cpp_dir, "c++.exe")) + file.create(file.path(fake_bin_dir, "make.exe")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ - local_mocked_bindings(current_r_version = function() numeric_version("4.10.0")) - expect_equal(rtools4x_version(), "45") + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.1.0"), + short_path = function(path) path, + repair_path = function(path) path + ) + withr::local_envvar(c(RTOOLS40_HOME = fake_home, R_ARCH = "/x64")) + result <- toolchain_PATH_env_var() + expect_false(is.null(result)) + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath( + c(fake_bin_dir, fake_cpp_dir), + winslash = "/", + mustWork = TRUE + ) + ) }) + + fake_cpp_dir <- file.path(fake_home, "mingw32", "bin") + dir.create(fake_cpp_dir, recursive = TRUE, showWarnings = FALSE) + file.create(file.path(fake_cpp_dir, "c++.exe")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ local_mocked_bindings( os_is_windows = function() TRUE, - current_r_version = function() numeric_version("4.10.0") + current_r_version = function() numeric_version("4.1.0"), + short_path = function(path) path, + repair_path = function(path) path + ) + withr::local_envvar(c(RTOOLS40_HOME = fake_home, R_ARCH = "/i386")) + result <- toolchain_PATH_env_var() + expect_false(is.null(result)) + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath( + c(fake_bin_dir, fake_cpp_dir), + winslash = "/", + mustWork = TRUE + ) ) - expect_equal(is_ucrt_toolchain(), TRUE) }) }) -test_that("rtools4x_toolchain_path prefers static-posix when available", { - skip_if(arch_is_aarch64()) - env_var <- paste0( - "RTOOLS", rtools4x_version(), - if (arch_is_aarch64()) "_AARCH64" else "", - "_HOME" +test_that("toolchain_PATH_env_var() compares R versions numerically", { + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_home <- withr::local_tempdir(pattern = "rtools-home-") + rcmd_calls <- 0L + .cmdstanr$TOOLCHAIN_PATH <- NULL + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.10.0"), + .cmdstanr_rcmd = function(...) { + rcmd_calls <<- rcmd_calls + 1L + file.path(fake_home, "toolchain") + } ) - fake_rtools_home <- withr::local_tempdir(pattern = "rtools-home-pref-") - dir.create(file.path(fake_rtools_home, "x86_64-w64-mingw32.static.posix", "bin"), - recursive = TRUE, showWarnings = FALSE) - dir.create(file.path(fake_rtools_home, "mingw64", "bin"), - recursive = TRUE, showWarnings = FALSE) - file.create(file.path(fake_rtools_home, "x86_64-w64-mingw32.static.posix", "bin", "g++.exe")) - file.create(file.path(fake_rtools_home, "mingw64", "bin", "g++.exe")) - - withr::with_envvar(setNames(fake_rtools_home, env_var), { - expect_equal( - rtools4x_toolchain_path(), - repair_path(file.path(fake_rtools_home, "x86_64-w64-mingw32.static.posix", "bin")) - ) - }) + withr::local_envvar(c(PATH = "")) + + result <- toolchain_PATH_env_var() + + expect_identical(rcmd_calls, 1L) + expect_null(result) }) -test_that("rtools4x_toolchain_path falls back to mingw64 for legacy layouts", { - skip_if(arch_is_aarch64()) - env_var <- paste0( - "RTOOLS", rtools4x_version(), - if (arch_is_aarch64()) "_AARCH64" else "", - "_HOME" - ) - fake_rtools_home <- withr::local_tempdir(pattern = "rtools-home-fallback-") - dir.create(file.path(fake_rtools_home, "mingw64", "bin"), - recursive = TRUE, showWarnings = FALSE) - file.create(file.path(fake_rtools_home, "mingw64", "bin", "g++.exe")) +test_that("toolchain_PATH_env_var() uses configured R_TOOLS_SOFT", { + skip_if(!os_is_windows()) + + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_home <- withr::local_tempdir(pattern = "rtools-home-") + fake_soft <- file.path(fake_home, "toolchain") + fake_bin_dir <- file.path(fake_home, "usr", "bin") + fake_cpp_dir <- file.path(fake_soft, "bin") + dir.create(fake_bin_dir, recursive = TRUE, showWarnings = FALSE) + dir.create(fake_cpp_dir, recursive = TRUE, showWarnings = FALSE) + file.create(file.path(fake_bin_dir, "make.exe")) + file.create(file.path(fake_cpp_dir, "c++.exe")) + file.create(file.path(fake_cpp_dir, "g++.exe")) - withr::with_envvar(setNames(fake_rtools_home, env_var), { - expect_equal( - rtools4x_toolchain_path(), - repair_path(file.path(fake_rtools_home, "mingw64", "bin")) + .cmdstanr$TOOLCHAIN_PATH <- NULL + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + .cmdstanr_rcmd = function(..., stdout = FALSE) fake_soft, + short_path = function(path) path, + repair_path = function(path) { + paste0( + "repaired:", + normalizePath(path, winslash = "/", mustWork = TRUE) + ) + } + ) + local_mocked_bindings( + Sys.which = function(command) stop("PATH fallback should not be used."), + .package = "base" + ) + result <- toolchain_PATH_env_var() + expected_dirs <- normalizePath( + c(fake_bin_dir, fake_cpp_dir), + winslash = "/", + mustWork = TRUE + ) + expect_identical( + result, + paste(paste0("repaired:", expected_dirs), collapse = ";") ) }) }) -test_that("rtools4x_toolchain_path prefers ABI-compatible legacy fallback", { - skip_if(arch_is_aarch64()) - env_var <- paste0( - "RTOOLS", rtools4x_version(), - if (arch_is_aarch64()) "_AARCH64" else "", - "_HOME" - ) - fake_rtools_home <- withr::local_tempdir(pattern = "rtools-home-abi-") - dir.create(file.path(fake_rtools_home, "mingw64", "bin"), - recursive = TRUE, showWarnings = FALSE) - dir.create(file.path(fake_rtools_home, "ucrt64", "bin"), - recursive = TRUE, showWarnings = FALSE) - file.create(file.path(fake_rtools_home, "mingw64", "bin", "g++.exe")) - file.create(file.path(fake_rtools_home, "ucrt64", "bin", "g++.exe")) - - withr::with_envvar(setNames(fake_rtools_home, env_var), { - local({ - local_mocked_bindings(is_ucrt_toolchain = function() FALSE) - expect_equal( - rtools4x_toolchain_path(), - repair_path(file.path(fake_rtools_home, "mingw64", "bin")) - ) - }) - local({ - local_mocked_bindings(is_ucrt_toolchain = function() TRUE) - expect_equal( - rtools4x_toolchain_path(), - repair_path(file.path(fake_rtools_home, "ucrt64", "bin")) - ) - }) - }) -}) +test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", { + skip_if(!os_is_windows()) + old_cache <- .cmdstanr$TOOLCHAIN_PATH -test_that("check_rtools4x_windows_toolchain reports checked toolchain paths", { - env_var <- paste0( - "RTOOLS", rtools4x_version(), - if (arch_is_aarch64()) "_AARCH64" else "", - "_HOME" - ) - fake_rtools_home <- withr::local_tempdir(pattern = "rtools-home-invalid-") - dir.create(file.path(fake_rtools_home, "usr", "bin"), - recursive = TRUE, showWarnings = FALSE) - file.create(file.path(fake_rtools_home, "usr", "bin", "make.exe")) - if (arch_is_aarch64()) { - dir.create(file.path(fake_rtools_home, "aarch64-w64-mingw32.static.posix", "bin"), - recursive = TRUE, showWarnings = FALSE) - } else { - dir.create(file.path(fake_rtools_home, "x86_64-w64-mingw32.static.posix", "bin"), - recursive = TRUE, showWarnings = FALSE) - dir.create(file.path(fake_rtools_home, "ucrt64", "bin"), - recursive = TRUE, showWarnings = FALSE) - dir.create(file.path(fake_rtools_home, "mingw64", "bin"), - recursive = TRUE, showWarnings = FALSE) - } + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) - withr::with_envvar(setNames(fake_rtools_home, env_var), { - expect_error( - check_rtools4x_windows_toolchain(), - "Checked the following paths:", - fixed = TRUE + fake_bin <- withr::local_tempdir(pattern = "rtools-fallback-") + file.create(file.path(fake_bin, "make.exe")) + file.create(file.path(fake_bin, "c++.exe")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + short_path = function(path) path, + repair_path = function(path) path + ) + local_mocked_bindings( + .cmdstanr_rcmd = function(..., stdout = FALSE) { + warning("Rcmd config failed") + structure( + "ERROR: no information for variable 'R_TOOLS_SOFT'", + status = 1L + ) + } + ) + withr::local_envvar(c(PATH = fake_bin)) + expect_no_warning(result <- toolchain_PATH_env_var()) + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath(fake_bin, winslash = "/", mustWork = TRUE) ) }) }) -test_that("toolchain_PATH_env_var() handles missing and configured Rtools homes", { +test_that("toolchain_PATH_env_var() searches PATH when R_TOOLS_SOFT is empty", { + skip_if(!os_is_windows()) + + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_bin <- withr::local_tempdir(pattern = "rtools-fallback-") + file_exists_calls <- character() + which_calls <- character() + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ - local_mocked_bindings(os_is_windows = function() FALSE) - expect_null(toolchain_PATH_env_var()) + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + .cmdstanr_rcmd = function(..., stdout = FALSE) "", + short_path = function(path) path, + repair_path = function(path) path + ) + local_mocked_bindings( + file.exists = function(path) { + file_exists_calls <<- c(file_exists_calls, path) + path %in% c("/usr/bin/make.exe", "/bin/c++.exe") + }, + Sys.which = function(command) { + which_calls <<- c(which_calls, command) + file.path(fake_bin, paste0(command, ".exe")) + }, + .package = "base" + ) + result <- toolchain_PATH_env_var() + expect_identical(file_exists_calls, character()) + expect_identical(which_calls, c("make", "c++")) + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath(fake_bin, winslash = "/", mustWork = TRUE) + ) }) +}) + +test_that("toolchain_PATH_env_var() returns NULL when both approaches fail", { + skip_if(!os_is_windows()) + + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ local_mocked_bindings( os_is_windows = function() TRUE, - rtools4x_home_path = function() "" + current_r_version = function() numeric_version("4.2.0"), + short_path = function(path) path, + repair_path = function(path) path ) - expect_null(toolchain_PATH_env_var()) + # Mock .cmdstanr_rcmd to return empty string + local_mocked_bindings( + .cmdstanr_rcmd = function(..., stdout = FALSE) "" + ) + withr::local_envvar(c(PATH = "")) + result <- toolchain_PATH_env_var() + expect_null(result) }) +}) + +test_that("toolchain_PATH_env_var() returns NULL when only one tool in PATH", { + skip_if(!os_is_windows()) + + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_bin <- withr::local_tempdir(pattern = "rtools-partial-") + file.create(file.path(fake_bin, "make")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ local_mocked_bindings( os_is_windows = function() TRUE, - rtools4x_home_path = function() "C:/rtools", - rtools4x_toolchain_path = function() "C:/rtools/ucrt64/bin", + current_r_version = function() numeric_version("4.2.0"), + short_path = function(path) path, repair_path = function(path) path ) - expect_equal( - toolchain_PATH_env_var(), - "C:/rtools/usr/bin;C:/rtools/ucrt64/bin" + # Mock .cmdstanr_rcmd to return empty (triggers fallback) + local_mocked_bindings( + .cmdstanr_rcmd = function(..., stdout = FALSE) "" ) + withr::local_envvar(c(PATH = fake_bin)) + result <- toolchain_PATH_env_var() + # Should return NULL because c++ was not found + expect_null(result) }) }) -test_that("check_rtools4x_windows_toolchain reports missing Rtools and make", { - fake_rtools_home <- withr::local_tempdir(pattern = "rtools-home-missing-") +test_that("toolchain_PATH_env_var() falls back to PATH when executables missing at R_TOOLS_SOFT", { + skip_if(!os_is_windows()) + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_soft <- withr::local_tempdir(pattern = "rtools-soft-") + dir.create(file.path(fake_soft, "bin"), recursive = TRUE, showWarnings = FALSE) + # Note: no make.exe or c++.exe created + + fake_bin <- withr::local_tempdir(pattern = "rtools-path-") + file.create(file.path(fake_bin, "make.exe")) + file.create(file.path(fake_bin, "c++.exe")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ local_mocked_bindings( - rtools4x_home_path = function() "", - rtools4x_version = function() "44" + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + short_path = function(path) path, + repair_path = function(path) path ) - expect_error( - check_rtools4x_windows_toolchain(), - "restart R, and then run cmdstanr::check_cmdstan_toolchain()", - fixed = TRUE + # Rcmd returns a valid path, but executables don't exist there + local_mocked_bindings( + .cmdstanr_rcmd = function(..., stdout = FALSE) fake_soft + ) + withr::local_envvar(c(PATH = fake_bin)) + result <- toolchain_PATH_env_var() + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath(fake_bin, winslash = "/", mustWork = TRUE) ) }) +}) - dir.create(file.path(fake_rtools_home, "usr", "bin"), - recursive = TRUE, showWarnings = FALSE) +test_that("toolchain_PATH_env_var() preserves configured compiler", { + skip_if(!os_is_windows()) + + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_home <- withr::local_tempdir(pattern = "rtools-home-") + fake_soft <- file.path(fake_home, "toolchain") + fake_cpp_dir <- file.path(fake_soft, "bin") + fake_path_dir <- file.path(fake_home, "path", "bin") + dir.create(fake_cpp_dir, recursive = TRUE, showWarnings = FALSE) + dir.create(fake_path_dir, recursive = TRUE, showWarnings = FALSE) + file.create(file.path(fake_cpp_dir, "c++.exe")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ local_mocked_bindings( - rtools4x_home_path = function() fake_rtools_home, - rtools4x_version = function() "44" + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + .cmdstanr_rcmd = function(..., stdout = FALSE) fake_soft, + short_path = function(path) path, + repair_path = function(path) path + ) + local_mocked_bindings( + Sys.which = function(command) { + if (command != "make") { + stop("Compiler PATH fallback should not be used.") + } + file.path(fake_path_dir, "make.exe") + }, + .package = "base" ) - expect_error( - check_rtools4x_windows_toolchain(), - "restart R, and then run cmdstanr::check_cmdstan_toolchain()", - fixed = TRUE + result <- toolchain_PATH_env_var() + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath( + c(fake_path_dir, fake_cpp_dir), + winslash = "/", + mustWork = TRUE + ) ) }) }) -test_that("check_rtools4x_windows_toolchain validates install path and empty candidates", { +test_that("toolchain_PATH_env_var() preserves configured make", { + skip_if(!os_is_windows()) + + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_home <- withr::local_tempdir(pattern = "rtools-home-") + fake_soft <- file.path(fake_home, "toolchain") + fake_bin_dir <- file.path(fake_home, "usr", "bin") + fake_path_dir <- file.path(fake_home, "path", "bin") + dir.create(fake_bin_dir, recursive = TRUE, showWarnings = FALSE) + dir.create(fake_path_dir, recursive = TRUE, showWarnings = FALSE) + file.create(file.path(fake_bin_dir, "make.exe")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ local_mocked_bindings( - rtools4x_home_path = function() "C:/Program Files/Rtools44", - rtools4x_version = function() "44" + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + .cmdstanr_rcmd = function(..., stdout = FALSE) fake_soft, + short_path = function(path) path, + repair_path = function(path) path ) - expect_error( - check_rtools4x_windows_toolchain(), - "Please reinstall the appropriate Rtools version for this R installation to a valid path", - fixed = TRUE + local_mocked_bindings( + Sys.which = function(command) { + if (command != "c++") { + stop("Make PATH fallback should not be used.") + } + file.path(fake_path_dir, "c++.exe") + }, + .package = "base" + ) + result <- toolchain_PATH_env_var() + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath( + c(fake_bin_dir, fake_path_dir), + winslash = "/", + mustWork = TRUE + ) ) }) +}) - fake_rtools_home <- withr::local_tempdir(pattern = "rtools-home-empty-") - dir.create(file.path(fake_rtools_home, "usr", "bin"), - recursive = TRUE, showWarnings = FALSE) - file.create(file.path(fake_rtools_home, "usr", "bin", "make.exe")) +test_that("toolchain_PATH_env_var() rejects unsafe toolchain paths", { + skip_if(!os_is_windows()) + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_home <- withr::local_tempdir(pattern = "rtools path-") + fake_soft <- file.path(fake_home, "toolchain") + fake_bin_dir <- file.path(fake_home, "usr", "bin") + fake_cpp_dir <- file.path(fake_soft, "bin") + dir.create(fake_bin_dir, recursive = TRUE, showWarnings = FALSE) + dir.create(fake_cpp_dir, recursive = TRUE, showWarnings = FALSE) + file.create(file.path(fake_bin_dir, "make.exe")) + file.create(file.path(fake_cpp_dir, "c++.exe")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL local({ local_mocked_bindings( - rtools4x_home_path = function() fake_rtools_home, - rtools4x_version = function() "44", - rtools4x_toolchain_candidates = function() character() + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + .cmdstanr_rcmd = function(..., stdout = FALSE) fake_soft, + short_path = function(path) path, + repair_path = function(path) path ) - expect_error( - check_rtools4x_windows_toolchain(), - "restart R, and then run cmdstanr::check_cmdstan_toolchain()", - fixed = TRUE + local_mocked_bindings( + Sys.which = function(command) stop("PATH fallback should not be used."), + .package = "base" ) + expect_snapshot(error = TRUE, toolchain_PATH_env_var()) }) }) -test_that("check_cmdstan_toolchain(fix = TRUE) is deprecated", { - expect_snapshot( - check_cmdstan_toolchain(fix = TRUE, quiet = TRUE) - ) +test_that("check_rtools4x_windows_toolchain() stops when no toolchain found", { + skip_if(!os_is_windows()) + + local_mocked_bindings(toolchain_PATH_env_var = function() NULL) + expect_snapshot(error = TRUE, check_rtools4x_windows_toolchain()) +}) + +test_that("is_ucrt_toolchain() returns correct values for R versions", { + skip_if(!os_is_windows()) + + # is_ucrt_toolchain() is TRUE for R 4.2.x – 4.x.x on Windows + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0") + ) + expect_true(is_ucrt_toolchain()) + }) + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.4.0") + ) + expect_true(is_ucrt_toolchain()) + }) + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.1.0") + ) + expect_false(is_ucrt_toolchain()) + }) + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("5.0.0") + ) + expect_false(is_ucrt_toolchain()) + }) + local({ + local_mocked_bindings(os_is_windows = function() FALSE) + expect_false(is_ucrt_toolchain()) + }) })