From 32a7dbd50192559ee3fb8882798567e5ed1e7dc4 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 22 Jul 2026 23:08:18 +0800 Subject: [PATCH 01/16] Simplify the lookup process for windows toolchains --- .github/workflows/R-CMD-check.yaml | 26 ---- R/install.R | 169 +++++------------------ tests/testthat/test-install.R | 209 ----------------------------- 3 files changed, 32 insertions(+), 372 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 86b7436f7..650a3c129 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,28 +78,6 @@ jobs: cache: "always" extra-packages: any::rcmdcheck, local::., ${{ matrix.config.os == 'windows-11-arm' && 'RcppCore/RcppParallel' || '' }} - - name: Debug Windows toolchain resolution - if: ${{ runner.os == 'Windows' }} - 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") - } - 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.") - } - shell: Rscript {0} - - name: Install POCL on Ubuntu Runners if: ${{ matrix.config.os == 'ubuntu-latest' }} run: | diff --git a/R/install.R b/R/install.R index ea7ca089e..a2bd8e7e8 100644 --- a/R/install.R +++ b/R/install.R @@ -614,54 +614,11 @@ 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,", + "\nNo C++ toolchain was found for your current installation.", + "\nPlease install or reinstall the appropriate Rtools version for this R installation or add it to your PATH", "\nrestart R, and then run cmdstanr::check_cmdstan_toolchain().", call. = FALSE ) @@ -751,104 +708,42 @@ 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() - ) -} - -#' 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") - } else { - c("x86_64-w64-mingw32.static.posix", "mingw64", "ucrt64") - } - 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++")))) -} - -#' 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("") - } - # 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]]) + # R 4.0 did not include the toolchain in the PATH by default + # or set the R_TOOLS_SOFT or LOCAL_SOFT config variables, so + # we use the RTOOLS40_HOME environment variable instead + if (R.version$major == 4 && R.version$minor < 2) { + rtools40_home <- repair_path(Sys.getenv("RTOOLS40_HOME")) + rtools_dir <- file.path(rtools40_home, "ucrt64", "bin") + rtools_bin_dir <- file.path(rtools40_home, "usr", "bin") + if (file.exists(file.path(rtools_bin_dir, "make.exe")) && + file.exists(file.path(rtools_dir, "c++.exe"))) { + return(paste0(rtools_bin_dir, ";", rtools_dir)) + } else { + return(NULL) + } } - 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 -} + # 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' + rtools_soft <- repair_path(tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)) + rtools_dir <- file.path(rtools_dir, "bin") + rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") -rtools4x_home_path <- function() { - rtools_ver <- rtools4x_version() - if (arch_is_aarch64()) { - rtools_ver <- paste0(rtools_ver, "_AARCH64") + if (file.exists(file.path(rtools_bin_dir, "make.exe")) && + file.exists(file.path(rtools_dir, "c++.exe"))) { + return(paste0(rtools_bin_dir, ";", rtools_dir)) } - 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 the configured toolchain location is empty, search the PATH + bin_path <- repair_path(dirname(Sys.which("make"))) + cxx_path <- repair_path(dirname(Sys.which("c++"))) + if (bin_path != "" && cxx_path != "") { + return(paste0(bin_path, ";", cxx_path)) } - path + NULL } assert_supported_requested_cmdstan_version <- function(version, source = "version") { diff --git a/tests/testthat/test-install.R b/tests/testthat/test-install.R index ec4a7bca4..973c3861d 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -304,215 +304,6 @@ test_that("deprecated CMDSTANR_USE_MSYS_TOOLCHAIN is ignored with warning", { }) }) -test_that("Rtools helpers compare R versions numerically", { - local({ - local_mocked_bindings(current_r_version = function() numeric_version("4.10.0")) - expect_equal(rtools4x_version(), "45") - }) - local({ - local_mocked_bindings( - os_is_windows = function() TRUE, - current_r_version = function() numeric_version("4.10.0") - ) - 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" - ) - 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")) - ) - }) -}) - -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")) - - withr::with_envvar(setNames(fake_rtools_home, env_var), { - expect_equal( - rtools4x_toolchain_path(), - repair_path(file.path(fake_rtools_home, "mingw64", "bin")) - ) - }) -}) - -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("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) - } - - withr::with_envvar(setNames(fake_rtools_home, env_var), { - expect_error( - check_rtools4x_windows_toolchain(), - "Checked the following paths:", - fixed = TRUE - ) - }) -}) - -test_that("toolchain_PATH_env_var() handles missing and configured Rtools homes", { - local({ - local_mocked_bindings(os_is_windows = function() FALSE) - expect_null(toolchain_PATH_env_var()) - }) - local({ - local_mocked_bindings( - os_is_windows = function() TRUE, - rtools4x_home_path = function() "" - ) - expect_null(toolchain_PATH_env_var()) - }) - local({ - local_mocked_bindings( - os_is_windows = function() TRUE, - rtools4x_home_path = function() "C:/rtools", - rtools4x_toolchain_path = function() "C:/rtools/ucrt64/bin", - repair_path = function(path) path - ) - expect_equal( - toolchain_PATH_env_var(), - "C:/rtools/usr/bin;C:/rtools/ucrt64/bin" - ) - }) -}) - -test_that("check_rtools4x_windows_toolchain reports missing Rtools and make", { - fake_rtools_home <- withr::local_tempdir(pattern = "rtools-home-missing-") - - local({ - local_mocked_bindings( - rtools4x_home_path = function() "", - rtools4x_version = function() "44" - ) - expect_error( - check_rtools4x_windows_toolchain(), - "restart R, and then run cmdstanr::check_cmdstan_toolchain()", - fixed = TRUE - ) - }) - - dir.create(file.path(fake_rtools_home, "usr", "bin"), - recursive = TRUE, showWarnings = FALSE) - local({ - local_mocked_bindings( - rtools4x_home_path = function() fake_rtools_home, - rtools4x_version = function() "44" - ) - expect_error( - check_rtools4x_windows_toolchain(), - "restart R, and then run cmdstanr::check_cmdstan_toolchain()", - fixed = TRUE - ) - }) -}) - -test_that("check_rtools4x_windows_toolchain validates install path and empty candidates", { - local({ - local_mocked_bindings( - rtools4x_home_path = function() "C:/Program Files/Rtools44", - rtools4x_version = function() "44" - ) - expect_error( - check_rtools4x_windows_toolchain(), - "Please reinstall the appropriate Rtools version for this R installation to a valid path", - fixed = 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")) - - local({ - local_mocked_bindings( - rtools4x_home_path = function() fake_rtools_home, - rtools4x_version = function() "44", - rtools4x_toolchain_candidates = function() character() - ) - expect_error( - check_rtools4x_windows_toolchain(), - "restart R, and then run cmdstanr::check_cmdstan_toolchain()", - fixed = TRUE - ) - }) -}) - test_that("check_cmdstan_toolchain(fix = TRUE) is deprecated", { expect_snapshot( check_cmdstan_toolchain(fix = TRUE, quiet = TRUE) From a686db26ef58134e3fa02a47809ac418dba6de31 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 22 Jul 2026 23:10:52 +0800 Subject: [PATCH 02/16] Typo --- R/install.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/install.R b/R/install.R index a2bd8e7e8..b9ec6564e 100644 --- a/R/install.R +++ b/R/install.R @@ -728,7 +728,7 @@ toolchain_PATH_env_var <- function() { # This variable is set at installation since R 4.2 # e.g., 'C:/rtools45/x86_64-w64-mingw32.static.posix' rtools_soft <- repair_path(tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)) - rtools_dir <- file.path(rtools_dir, "bin") + rtools_dir <- file.path(rtools_soft, "bin") rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") if (file.exists(file.path(rtools_bin_dir, "make.exe")) && From be529b924535476b89ddaeb267608a78a72056a9 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 22 Jul 2026 23:39:41 +0800 Subject: [PATCH 03/16] Missed mingw64 dir --- R/install.R | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/R/install.R b/R/install.R index b9ec6564e..fa18b2b73 100644 --- a/R/install.R +++ b/R/install.R @@ -714,10 +714,18 @@ toolchain_PATH_env_var <- function() { # we use the RTOOLS40_HOME environment variable instead if (R.version$major == 4 && R.version$minor < 2) { rtools40_home <- repair_path(Sys.getenv("RTOOLS40_HOME")) - rtools_dir <- file.path(rtools40_home, "ucrt64", "bin") + rtools_dir <- NULL rtools_bin_dir <- file.path(rtools40_home, "usr", "bin") - if (file.exists(file.path(rtools_bin_dir, "make.exe")) && - file.exists(file.path(rtools_dir, "c++.exe"))) { + if (file.exists(file.path(rtools_bin_dir, "make.exe"))) { + mingw64_dir <- file.path(rtools40_home, "mingw64", "bin") + ucrt64_dir <- file.path(rtools40_home, "ucrt64", "bin") + if (file.exists(file.path(mingw64_dir, "c++.exe"))) { + rtools_dir <- mingw64_dir + } else if (file.exists(file.path(ucrt64_dir, "c++.exe"))) { + rtools_dir <- ucrt64_dir + } else { + return(NULL) + } return(paste0(rtools_bin_dir, ";", rtools_dir)) } else { return(NULL) From 5f91fea6cc2eb8f7d83fcd26fdbc59d035943381 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 22 Jul 2026 23:44:09 +0800 Subject: [PATCH 04/16] Enable alternate toolchains in PATH for R4.0 --- R/install.R | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/R/install.R b/R/install.R index fa18b2b73..e44c0b08c 100644 --- a/R/install.R +++ b/R/install.R @@ -728,22 +728,20 @@ toolchain_PATH_env_var <- function() { } return(paste0(rtools_bin_dir, ";", rtools_dir)) } else { - return(NULL) + # 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' + rtools_soft <- repair_path(tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)) + rtools_dir <- file.path(rtools_soft, "bin") + rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") + + if (file.exists(file.path(rtools_bin_dir, "make.exe")) && + file.exists(file.path(rtools_dir, "c++.exe"))) { + return(paste0(rtools_bin_dir, ";", rtools_dir)) + } } } - # 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' - rtools_soft <- repair_path(tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)) - rtools_dir <- file.path(rtools_soft, "bin") - rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") - - if (file.exists(file.path(rtools_bin_dir, "make.exe")) && - file.exists(file.path(rtools_dir, "c++.exe"))) { - return(paste0(rtools_bin_dir, ";", rtools_dir)) - } - # If the configured toolchain location is empty, search the PATH bin_path <- repair_path(dirname(Sys.which("make"))) cxx_path <- repair_path(dirname(Sys.which("c++"))) From d5b5dd8319697136c4a4b5c4a87d900e50780269 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 22 Jul 2026 23:45:49 +0800 Subject: [PATCH 05/16] Version mismatch --- R/install.R | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/R/install.R b/R/install.R index e44c0b08c..f3e78c16b 100644 --- a/R/install.R +++ b/R/install.R @@ -727,18 +727,18 @@ toolchain_PATH_env_var <- function() { return(NULL) } return(paste0(rtools_bin_dir, ";", rtools_dir)) - } else { - # 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' - rtools_soft <- repair_path(tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)) - rtools_dir <- file.path(rtools_soft, "bin") - rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") - - if (file.exists(file.path(rtools_bin_dir, "make.exe")) && - file.exists(file.path(rtools_dir, "c++.exe"))) { - return(paste0(rtools_bin_dir, ";", rtools_dir)) - } + } + } else { + # 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' + rtools_soft <- repair_path(tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)) + rtools_dir <- file.path(rtools_soft, "bin") + rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") + + if (file.exists(file.path(rtools_bin_dir, "make.exe")) && + file.exists(file.path(rtools_dir, "c++.exe"))) { + return(paste0(rtools_bin_dir, ";", rtools_dir)) } } From 36e872dd14b96f9299679c1a4516c8714e13467e Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 23 Jul 2026 00:20:18 +0800 Subject: [PATCH 06/16] Account for GHA conflicts --- R/install.R | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/R/install.R b/R/install.R index f3e78c16b..480af746b 100644 --- a/R/install.R +++ b/R/install.R @@ -712,20 +712,12 @@ toolchain_PATH_env_var <- function() { # R 4.0 did not include the toolchain in the PATH by default # or set the R_TOOLS_SOFT or LOCAL_SOFT config variables, so # we use the RTOOLS40_HOME environment variable instead - if (R.version$major == 4 && R.version$minor < 2) { - rtools40_home <- repair_path(Sys.getenv("RTOOLS40_HOME")) - rtools_dir <- NULL + if (current_r_version() < "4.2.0") { + rtools40_home <- repair_path(Sys.getenv("RTOOLS40_HOME", "C:\\rtools40")) rtools_bin_dir <- file.path(rtools40_home, "usr", "bin") - if (file.exists(file.path(rtools_bin_dir, "make.exe"))) { - mingw64_dir <- file.path(rtools40_home, "mingw64", "bin") - ucrt64_dir <- file.path(rtools40_home, "ucrt64", "bin") - if (file.exists(file.path(mingw64_dir, "c++.exe"))) { - rtools_dir <- mingw64_dir - } else if (file.exists(file.path(ucrt64_dir, "c++.exe"))) { - rtools_dir <- ucrt64_dir - } else { - return(NULL) - } + rtools_dir <- file.path(rtools40_home, "ucrt64", "bin") + if (file.exists(file.path(rtools_bin_dir, "make.exe")) && + file.exists(file.path(rtools_dir, "c++.exe"))) { return(paste0(rtools_bin_dir, ";", rtools_dir)) } } else { From d4f5289af3a9026394599b5f959e59aa2ed3cd39 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 23 Jul 2026 15:19:18 +0800 Subject: [PATCH 07/16] Account for 32bit rtools, cleanup --- R/install.R | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/R/install.R b/R/install.R index 480af746b..13c3f92d4 100644 --- a/R/install.R +++ b/R/install.R @@ -709,36 +709,34 @@ toolchain_PATH_env_var <- function() { return(NULL) } - # R 4.0 did not include the toolchain in the PATH by default - # or set the R_TOOLS_SOFT or LOCAL_SOFT config variables, so + # 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' + rtools_soft <- "" + + # R 4.0 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 <- repair_path(Sys.getenv("RTOOLS40_HOME", "C:\\rtools40")) - rtools_bin_dir <- file.path(rtools40_home, "usr", "bin") - rtools_dir <- file.path(rtools40_home, "ucrt64", "bin") - if (file.exists(file.path(rtools_bin_dir, "make.exe")) && - file.exists(file.path(rtools_dir, "c++.exe"))) { - return(paste0(rtools_bin_dir, ";", rtools_dir)) - } + r_arch <- ifelse(Sys.getenv("R_ARCH") == "/i386", "mingw32", "mingw64") + rtools_soft <- file.path(rtools40_home, r_arch) } else { - # 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' rtools_soft <- repair_path(tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)) - rtools_dir <- file.path(rtools_soft, "bin") - rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") + } - if (file.exists(file.path(rtools_bin_dir, "make.exe")) && - file.exists(file.path(rtools_dir, "c++.exe"))) { - return(paste0(rtools_bin_dir, ";", rtools_dir)) - } + rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") + rtools_cpp_dir <- file.path(rtools_soft, "bin") + + if (!file.exists(file.path(rtools_bin_dir, "make.exe")) || + !file.exists(file.path(rtools_cpp_dir, "c++.exe"))) { + + # If the configured toolchain location is empty, search the PATH + rtools_bin_dir <- repair_path(dirname(Sys.which("make"))) + rtools_cpp_dir <- repair_path(dirname(Sys.which("c++"))) } - # If the configured toolchain location is empty, search the PATH - bin_path <- repair_path(dirname(Sys.which("make"))) - cxx_path <- repair_path(dirname(Sys.which("c++"))) if (bin_path != "" && cxx_path != "") { - return(paste0(bin_path, ";", cxx_path)) + return(paste0(rtools_bin_dir, ";", rtools_cpp_dir)) } NULL From 11b03f133d2e39443c2bd44c55853770649a0b96 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 23 Jul 2026 15:24:42 +0800 Subject: [PATCH 08/16] Typos --- R/install.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/install.R b/R/install.R index 13c3f92d4..6392517a2 100644 --- a/R/install.R +++ b/R/install.R @@ -735,7 +735,7 @@ toolchain_PATH_env_var <- function() { rtools_cpp_dir <- repair_path(dirname(Sys.which("c++"))) } - if (bin_path != "" && cxx_path != "") { + if (rtools_bin_dir != "" && rtools_cpp_dir != "") { return(paste0(rtools_bin_dir, ";", rtools_cpp_dir)) } From 1b009ba7b707f4b9ea034be7d4f535aa88666c61 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 6 Aug 2026 15:38:59 +0800 Subject: [PATCH 09/16] Cache toolchain lookup, add tests --- R/install.R | 28 +++-- R/path.R | 1 + tests/testthat/test-install.R | 229 ++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 8 deletions(-) diff --git a/R/install.R b/R/install.R index eafb5d9c7..f90233d61 100644 --- a/R/install.R +++ b/R/install.R @@ -706,9 +706,15 @@ cmdstan_arch_suffix <- function(version = NULL) { paste0("-linux-", selected_arch) } +#' Thin wrapper around `tools::Rcmd()` for testability +#' @noRd +.cmdstanr_rcmd <- function(...) tools::Rcmd(...) + toolchain_PATH_env_var <- function() { - if (!os_is_windows()) { - return(NULL) + # Return cached result if available (toolchain doesn't change mid-session) + # 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 @@ -716,14 +722,17 @@ toolchain_PATH_env_var <- function() { # e.g., 'C:/rtools45/x86_64-w64-mingw32.static.posix' rtools_soft <- "" - # R 4.0 did not set the R_TOOLS_SOFT config variable, so + # 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 <- repair_path(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 { - rtools_soft <- repair_path(tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)) + rtools_soft <- tryCatch( + trimws(.cmdstanr_rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)), + error = function(e) "" + ) } rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") @@ -733,15 +742,18 @@ toolchain_PATH_env_var <- function() { !file.exists(file.path(rtools_cpp_dir, "c++.exe"))) { # If the configured toolchain location is empty, search the PATH - rtools_bin_dir <- repair_path(dirname(Sys.which("make"))) - rtools_cpp_dir <- repair_path(dirname(Sys.which("c++"))) + # R4.2+ prepends the toolchain directory to the path, so will be found first + make_path <- Sys.which("make") + cpp_path <- Sys.which("c++") + rtools_bin_dir <- if (nzchar(make_path)) repair_path(dirname(make_path)) else "" + rtools_cpp_dir <- if (nzchar(cpp_path)) repair_path(dirname(cpp_path)) else "" } if (rtools_bin_dir != "" && rtools_cpp_dir != "") { - return(paste0(rtools_bin_dir, ";", rtools_cpp_dir)) + .cmdstanr$TOOLCHAIN_PATH <- paste0(rtools_bin_dir, ";", rtools_cpp_dir) } - NULL + .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/tests/testthat/test-install.R b/tests/testthat/test-install.R index 973c3861d..9e706ae34 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -309,3 +309,232 @@ test_that("check_cmdstan_toolchain(fix = TRUE) is deprecated", { 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", { + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_home <- withr::local_tempdir(pattern = "rtools40-home-") + # Create the expected directory structure for R 4.0/4.1 + dir.create(file.path(fake_home, "mingw64", "bin"), recursive = TRUE, showWarnings = FALSE) + dir.create(file.path(fake_home, "usr", "bin"), recursive = TRUE, showWarnings = FALSE) + file.create(file.path(fake_home, "mingw64", "bin", "c++.exe")) + file.create(file.path(fake_home, "usr", "bin", "make.exe")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.1.0"), + repair_path = function(path) path + ) + withr::local_envvar(c(RTOOLS40_HOME = fake_home, R_ARCH = "")) + result <- toolchain_PATH_env_var() + expect_false(is.null(result)) + expect_identical( + result, + paste0(file.path(fake_home, "usr", "bin"), ";", file.path(fake_home, "mingw64", "bin")) + ) + }) + + .cmdstanr$TOOLCHAIN_PATH <- NULL + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.1.0"), + 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)) + expect_identical( + result, + paste0(file.path(fake_home, "usr", "bin"), ";", file.path(fake_home, "mingw32", "bin")) + ) + }) +}) + +test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", { + old_cache <- .cmdstanr$TOOLCHAIN_PATH + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) + + fake_bin <- withr::local_tempdir(pattern = "rtools-fallback-") + file.create(file.path(fake_bin, "make")) + file.create(file.path(fake_bin, "c++")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + repair_path = function(path) path + ) + # Mock .cmdstanr_rcmd to simulate a failure + local_mocked_bindings( + .cmdstanr_rcmd = function(..., stdout = FALSE) stop("Rcmd not found") + ) + withr::local_envvar(c(PATH = fake_bin)) + result <- toolchain_PATH_env_var() + # Should fall back to Sys.which() and find the fake binaries + expect_false(is.null(result)) + expect_identical(result, paste0(fake_bin, ";", fake_bin)) + }) +}) + +test_that("toolchain_PATH_env_var() returns NULL when both approaches fail", { + 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, + current_r_version = function() numeric_version("4.2.0"), + repair_path = function(path) path + ) + # Mock .cmdstanr_rcmd to return empty string + local_mocked_bindings( + .cmdstanr_rcmd = function(..., stdout = FALSE) "" + ) + # Mock Sys.which to return empty (no tools in PATH) + local_mocked_bindings( + Sys.which = function(name) "" + ) + result <- toolchain_PATH_env_var() + expect_null(result) + }) +}) + +test_that("toolchain_PATH_env_var() returns NULL when only one tool in PATH", { + 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, + current_r_version = function() numeric_version("4.2.0"), + repair_path = function(path) path + ) + # Mock .cmdstanr_rcmd to return empty (triggers fallback) + local_mocked_bindings( + .cmdstanr_rcmd = function(..., stdout = FALSE) "" + ) + # Only make is in PATH, c++ is not + local_mocked_bindings( + Sys.which = function(name) { + if (name == "make") file.path(fake_bin, "make") else "" + } + ) + result <- toolchain_PATH_env_var() + # Should return NULL because c++ was not found + expect_null(result) + }) +}) + +test_that("toolchain_PATH_env_var() falls back to PATH when executables missing at R_TOOLS_SOFT", { + 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")) + file.create(file.path(fake_bin, "c++")) + + .cmdstanr$TOOLCHAIN_PATH <- NULL + local({ + local_mocked_bindings( + os_is_windows = function() TRUE, + current_r_version = function() numeric_version("4.2.0"), + repair_path = function(path) path + ) + # 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() + # Should fall back to PATH and find the tools there + expect_false(is.null(result)) + expect_identical(result, paste0(fake_bin, ";", fake_bin)) + }) +}) + +test_that("check_rtools4x_windows_toolchain() stops when no toolchain found", { + local_mocked_bindings(toolchain_PATH_env_var = function() NULL) + expect_error( + check_rtools4x_windows_toolchain(), + "No C++ toolchain was found", + fixed = TRUE + ) +}) + +test_that("is_ucrt_toolchain() returns correct values for R versions", { + # 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()) + }) +}) From 4d39a45c279a81750e67ae4fa72eb362b6aa3fae Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 6 Aug 2026 16:30:56 +0800 Subject: [PATCH 10/16] Correctly skip toolchain tests on non-windows, test handling for spaces --- .github/workflows/R-CMD-check.yaml | 8 ++++++++ R/install.R | 12 ++++++------ tests/testthat/test-install.R | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 6d6530e94..4c28838ef 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -65,6 +65,14 @@ jobs: with: r-version: ${{ matrix.config.r }} + - name: Test Windows toolchain in directory with spaces + if: ${{ runner.os == 'windows' && matrix.config.r == '4.1'}} + run: | + 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: Install R Package Build Dependencies on MacOS if: ${{ runner.os == 'macOS' }} uses: r-hub/actions/setup-r-sysreqs@v1 diff --git a/R/install.R b/R/install.R index f90233d61..3867fd751 100644 --- a/R/install.R +++ b/R/install.R @@ -706,8 +706,7 @@ cmdstan_arch_suffix <- function(version = NULL) { paste0("-linux-", selected_arch) } -#' Thin wrapper around `tools::Rcmd()` for testability -#' @noRd +# Thin wrapper around `tools::Rcmd()` to allow mocking .cmdstanr_rcmd <- function(...) tools::Rcmd(...) toolchain_PATH_env_var <- function() { @@ -725,7 +724,7 @@ toolchain_PATH_env_var <- function() { # 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 <- repair_path(Sys.getenv("RTOOLS40_HOME", "C:\\rtools40")) + 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 { @@ -745,12 +744,13 @@ toolchain_PATH_env_var <- function() { # R4.2+ prepends the toolchain directory to the path, so will be found first make_path <- Sys.which("make") cpp_path <- Sys.which("c++") - rtools_bin_dir <- if (nzchar(make_path)) repair_path(dirname(make_path)) else "" - rtools_cpp_dir <- if (nzchar(cpp_path)) repair_path(dirname(cpp_path)) else "" + rtools_bin_dir <- ifelse(nzchar(make_path), dirname(make_path), "") + rtools_cpp_dir <- ifelse(nzchar(cpp_path), dirname(cpp_path), "") } if (rtools_bin_dir != "" && rtools_cpp_dir != "") { - .cmdstanr$TOOLCHAIN_PATH <- paste0(rtools_bin_dir, ";", rtools_cpp_dir) + # Use short path to protect against spaces + .cmdstanr$TOOLCHAIN_PATH <- paste0(repair_path(utils::shortPathName(c(rtools_bin_dir, rtools_cpp_dir))), collapse = ";") } .cmdstanr$TOOLCHAIN_PATH diff --git a/tests/testthat/test-install.R b/tests/testthat/test-install.R index 9e706ae34..ae2df3ac9 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -339,6 +339,8 @@ test_that("toolchain_PATH_env_var() caches result after first call", { }) 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) @@ -384,6 +386,8 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", { old_cache <- .cmdstanr$TOOLCHAIN_PATH + skip_if(!os_is_windows()) + on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) fake_bin <- withr::local_tempdir(pattern = "rtools-fallback-") @@ -410,6 +414,8 @@ test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", }) 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) @@ -434,6 +440,8 @@ test_that("toolchain_PATH_env_var() returns NULL when both approaches fail", { }) 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) @@ -464,6 +472,8 @@ test_that("toolchain_PATH_env_var() returns NULL when only one tool in PATH", { }) 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) @@ -495,6 +505,8 @@ test_that("toolchain_PATH_env_var() falls back to PATH when executables missing }) 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_error( check_rtools4x_windows_toolchain(), @@ -504,6 +516,8 @@ test_that("check_rtools4x_windows_toolchain() stops when no toolchain found", { }) 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( From 4220f04cd37baab3ed7f574d3c344dd27947761a Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 6 Aug 2026 19:02:56 +0800 Subject: [PATCH 11/16] shortName path expectations --- tests/testthat/test-install.R | 49 +++++++++++++++++------------------ 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/tests/testthat/test-install.R b/tests/testthat/test-install.R index ae2df3ac9..987bdab19 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -344,12 +344,15 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { old_cache <- .cmdstanr$TOOLCHAIN_PATH on.exit(.cmdstanr$TOOLCHAIN_PATH <- old_cache) - fake_home <- withr::local_tempdir(pattern = "rtools40-home-") + 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(file.path(fake_home, "mingw64", "bin"), recursive = TRUE, showWarnings = FALSE) - dir.create(file.path(fake_home, "usr", "bin"), recursive = TRUE, showWarnings = FALSE) - file.create(file.path(fake_home, "mingw64", "bin", "c++.exe")) - file.create(file.path(fake_home, "usr", "bin", "make.exe")) + 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({ @@ -358,15 +361,19 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { current_r_version = function() numeric_version("4.1.0"), repair_path = function(path) path ) - withr::local_envvar(c(RTOOLS40_HOME = fake_home, R_ARCH = "")) + withr::local_envvar(c(RTOOLS40_HOME = fake_home, R_ARCH = "/x64")) result <- toolchain_PATH_env_var() expect_false(is.null(result)) expect_identical( result, - paste0(file.path(fake_home, "usr", "bin"), ";", file.path(fake_home, "mingw64", "bin")) + paste0(repair_path(utils::shortPathName(c(fake_bin_dir, fake_cpp_dir))), collapse = ";") ) }) + 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( @@ -379,20 +386,20 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { expect_false(is.null(result)) expect_identical( result, - paste0(file.path(fake_home, "usr", "bin"), ";", file.path(fake_home, "mingw32", "bin")) + paste0(repair_path(utils::shortPathName(c(fake_bin_dir, fake_cpp_dir))), collapse = ";") ) }) }) test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", { - old_cache <- .cmdstanr$TOOLCHAIN_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-fallback-") - file.create(file.path(fake_bin, "make")) - file.create(file.path(fake_bin, "c++")) + file.create(file.path(fake_bin, "make.exe")) + file.create(file.path(fake_bin, "c++.exe")) .cmdstanr$TOOLCHAIN_PATH <- NULL local({ @@ -409,7 +416,7 @@ test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", result <- toolchain_PATH_env_var() # Should fall back to Sys.which() and find the fake binaries expect_false(is.null(result)) - expect_identical(result, paste0(fake_bin, ";", fake_bin)) + paste0(repair_path(utils::shortPathName(c(fake_bin, fake_bin))), collapse = ";") }) }) @@ -430,10 +437,7 @@ test_that("toolchain_PATH_env_var() returns NULL when both approaches fail", { local_mocked_bindings( .cmdstanr_rcmd = function(..., stdout = FALSE) "" ) - # Mock Sys.which to return empty (no tools in PATH) - local_mocked_bindings( - Sys.which = function(name) "" - ) + withr::local_envvar(c(PATH = "")) result <- toolchain_PATH_env_var() expect_null(result) }) @@ -459,12 +463,7 @@ test_that("toolchain_PATH_env_var() returns NULL when only one tool in PATH", { local_mocked_bindings( .cmdstanr_rcmd = function(..., stdout = FALSE) "" ) - # Only make is in PATH, c++ is not - local_mocked_bindings( - Sys.which = function(name) { - if (name == "make") file.path(fake_bin, "make") else "" - } - ) + withr::local_envvar(c(PATH = fake_bin)) result <- toolchain_PATH_env_var() # Should return NULL because c++ was not found expect_null(result) @@ -482,8 +481,8 @@ test_that("toolchain_PATH_env_var() falls back to PATH when executables missing # Note: no make.exe or c++.exe created fake_bin <- withr::local_tempdir(pattern = "rtools-path-") - file.create(file.path(fake_bin, "make")) - file.create(file.path(fake_bin, "c++")) + file.create(file.path(fake_bin, "make.exe")) + file.create(file.path(fake_bin, "c++.exe")) .cmdstanr$TOOLCHAIN_PATH <- NULL local({ @@ -500,7 +499,7 @@ test_that("toolchain_PATH_env_var() falls back to PATH when executables missing result <- toolchain_PATH_env_var() # Should fall back to PATH and find the tools there expect_false(is.null(result)) - expect_identical(result, paste0(fake_bin, ";", fake_bin)) + paste0(repair_path(utils::shortPathName(c(fake_bin, fake_bin))), collapse = ";") }) }) From 4b634e7e60f5c8925114f7d9c96abd287f7719d7 Mon Sep 17 00:00:00 2001 From: jgabry Date: Thu, 6 Aug 2026 11:41:43 -0600 Subject: [PATCH 12/16] Handle failed Rtools config lookup --- R/install.R | 9 ++++++- tests/testthat/test-install.R | 48 ++++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/R/install.R b/R/install.R index 3867fd751..30cbd2b4e 100644 --- a/R/install.R +++ b/R/install.R @@ -729,9 +729,16 @@ toolchain_PATH_env_var <- function() { rtools_soft <- file.path(rtools40_home, r_arch) } else { rtools_soft <- tryCatch( - trimws(.cmdstanr_rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE)), + 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) + } } rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") diff --git a/tests/testthat/test-install.R b/tests/testthat/test-install.R index 987bdab19..09985d113 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -391,6 +391,29 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { }) }) +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") + } + ) + withr::local_envvar(c(PATH = "")) + + result <- toolchain_PATH_env_var() + + expect_identical(rcmd_calls, 1L) + expect_null(result) +}) + test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", { skip_if(!os_is_windows()) old_cache <- .cmdstanr$TOOLCHAIN_PATH @@ -408,15 +431,21 @@ test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", current_r_version = function() numeric_version("4.2.0"), repair_path = function(path) path ) - # Mock .cmdstanr_rcmd to simulate a failure local_mocked_bindings( - .cmdstanr_rcmd = function(..., stdout = FALSE) stop("Rcmd not found") + .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)) - result <- toolchain_PATH_env_var() - # Should fall back to Sys.which() and find the fake binaries - expect_false(is.null(result)) - paste0(repair_path(utils::shortPathName(c(fake_bin, fake_bin))), collapse = ";") + expect_no_warning(result <- toolchain_PATH_env_var()) + expect_identical( + result, + paste0(repair_path(utils::shortPathName(c(fake_bin, fake_bin))), collapse = ";") + ) }) }) @@ -497,9 +526,10 @@ test_that("toolchain_PATH_env_var() falls back to PATH when executables missing ) withr::local_envvar(c(PATH = fake_bin)) result <- toolchain_PATH_env_var() - # Should fall back to PATH and find the tools there - expect_false(is.null(result)) - paste0(repair_path(utils::shortPathName(c(fake_bin, fake_bin))), collapse = ";") + expect_identical( + result, + paste0(repair_path(utils::shortPathName(c(fake_bin, fake_bin))), collapse = ";") + ) }) }) From 3b18ca216f00676ef6d24fd15935ba9303381f64 Mon Sep 17 00:00:00 2001 From: jgabry Date: Thu, 6 Aug 2026 14:55:19 -0600 Subject: [PATCH 13/16] check for R_TOOLS_SOFT usage in workflow --- .github/workflows/R-CMD-check.yaml | 43 ++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 450bfc928..6df4f2d39 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -65,14 +65,6 @@ jobs: with: r-version: ${{ matrix.config.r }} - - name: Test Windows toolchain in directory with spaces - if: ${{ runner.os == 'windows' && matrix.config.r == '4.1'}} - run: | - 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: Install R Package Build Dependencies on MacOS if: ${{ runner.os == 'macOS' }} uses: r-hub/actions/setup-r-sysreqs@v1 @@ -86,6 +78,41 @@ jobs: cache: "always" extra-packages: any::rcmdcheck, local::., ${{ matrix.config.r == '4.1' && 'qs2=?ignore' || '' }} + - name: Test Windows toolchain in directory with spaces + if: ${{ runner.os == 'Windows' && matrix.config.r == '4.1' }} + run: | + 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: ${{ runner.os == 'Windows' && 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.") + } + 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} + - name: Install POCL on Ubuntu Runners if: ${{ matrix.config.os == 'ubuntu-latest' && matrix.config.opencl }} run: | From e2d182a383459686a0df020c3d67fadd0ce5a650 Mon Sep 17 00:00:00 2001 From: jgabry Date: Thu, 6 Aug 2026 14:55:34 -0600 Subject: [PATCH 14/16] Handle a few more edge cases --- NEWS.md | 1 + R/install.R | 39 ++++-- R/utils.R | 4 - tests/testthat/_snaps/install.md | 18 +++ tests/testthat/test-install.R | 210 +++++++++++++++++++++++++++++-- 5 files changed, 244 insertions(+), 28 deletions(-) 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 30cbd2b4e..59a280e50 100644 --- a/R/install.R +++ b/R/install.R @@ -619,8 +619,10 @@ check_rtools4x_windows_toolchain <- function(quiet = FALSE) { toolchain_path <- toolchain_PATH_env_var() if (is.null(toolchain_path)) { stop( - "\nNo C++ toolchain was found for your current installation.", - "\nPlease install or reinstall the appropriate Rtools version for this R installation or add it to your PATH", + "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 ) @@ -710,7 +712,7 @@ cmdstan_arch_suffix <- function(version = NULL) { .cmdstanr_rcmd <- function(...) tools::Rcmd(...) toolchain_PATH_env_var <- function() { - # Return cached result if available (toolchain doesn't change mid-session) + # 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) @@ -719,8 +721,6 @@ toolchain_PATH_env_var <- function() { # 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' - rtools_soft <- "" - # 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") { @@ -744,22 +744,35 @@ toolchain_PATH_env_var <- function() { rtools_bin_dir <- file.path(dirname(rtools_soft), "usr", "bin") rtools_cpp_dir <- file.path(rtools_soft, "bin") - if (!file.exists(file.path(rtools_bin_dir, "make.exe")) || - !file.exists(file.path(rtools_cpp_dir, "c++.exe"))) { - - # If the configured toolchain location is empty, search the PATH - # R4.2+ prepends the toolchain directory to the path, so will be found first + # 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") - cpp_path <- Sys.which("c++") rtools_bin_dir <- ifelse(nzchar(make_path), dirname(make_path), "") + } + 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), "") } if (rtools_bin_dir != "" && rtools_cpp_dir != "") { - # Use short path to protect against spaces - .cmdstanr$TOOLCHAIN_PATH <- paste0(repair_path(utils::shortPathName(c(rtools_bin_dir, rtools_cpp_dir))), collapse = ";") + 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 = ";") } + # Leave failed lookups uncached so later toolchain or PATH changes are found .cmdstanr$TOOLCHAIN_PATH } 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 09985d113..e957e409f 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -359,6 +359,7 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { 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")) @@ -366,7 +367,7 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { expect_false(is.null(result)) expect_identical( result, - paste0(repair_path(utils::shortPathName(c(fake_bin_dir, fake_cpp_dir))), collapse = ";") + paste(c(fake_bin_dir, fake_cpp_dir), collapse = ";") ) }) @@ -379,6 +380,7 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { 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 = "/i386")) @@ -386,7 +388,7 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { expect_false(is.null(result)) expect_identical( result, - paste0(repair_path(utils::shortPathName(c(fake_bin_dir, fake_cpp_dir))), collapse = ";") + paste(c(fake_bin_dir, fake_cpp_dir), collapse = ";") ) }) }) @@ -414,6 +416,49 @@ test_that("toolchain_PATH_env_var() compares R versions numerically", { expect_null(result) }) +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")) + + .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:", path) + ) + local_mocked_bindings( + Sys.which = function(command) stop("PATH fallback should not be used."), + .package = "base" + ) + result <- toolchain_PATH_env_var() + expect_identical( + result, + paste( + c( + paste0("repaired:", fake_bin_dir), + paste0("repaired:", fake_cpp_dir) + ), + collapse = ";" + ) + ) + }) +}) + test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", { skip_if(!os_is_windows()) old_cache <- .cmdstanr$TOOLCHAIN_PATH @@ -429,6 +474,7 @@ test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", 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( @@ -442,10 +488,44 @@ test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", ) withr::local_envvar(c(PATH = fake_bin)) expect_no_warning(result <- toolchain_PATH_env_var()) - expect_identical( - result, - paste0(repair_path(utils::shortPathName(c(fake_bin, fake_bin))), collapse = ";") + expect_identical(result, fake_bin) + }) +}) + +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() 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++")) + expect_identical(result, fake_bin) }) }) @@ -460,6 +540,7 @@ test_that("toolchain_PATH_env_var() returns NULL when both approaches fail", { 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 ) # Mock .cmdstanr_rcmd to return empty string @@ -486,6 +567,7 @@ test_that("toolchain_PATH_env_var() returns NULL when only one tool in PATH", { 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 ) # Mock .cmdstanr_rcmd to return empty (triggers fallback) @@ -518,6 +600,7 @@ test_that("toolchain_PATH_env_var() falls back to PATH when executables missing 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 ) # Rcmd returns a valid path, but executables don't exist there @@ -526,22 +609,127 @@ test_that("toolchain_PATH_env_var() falls back to PATH when executables missing ) withr::local_envvar(c(PATH = fake_bin)) result <- toolchain_PATH_env_var() + expect_identical(result, fake_bin) + }) +}) + +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( + 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" + ) + result <- toolchain_PATH_env_var() expect_identical( result, - paste0(repair_path(utils::shortPathName(c(fake_bin, fake_bin))), collapse = ";") + paste(c(fake_path_dir, fake_cpp_dir), collapse = ";") ) }) }) +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( + 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 != "c++") { + stop("Make PATH fallback should not be used.") + } + file.path(fake_path_dir, "c++.exe") + }, + .package = "base" + ) + result <- toolchain_PATH_env_var() + expect_identical( + result, + paste(c(fake_bin_dir, fake_path_dir), collapse = ";") + ) + }) +}) + +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( + 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) stop("PATH fallback should not be used."), + .package = "base" + ) + expect_snapshot(error = TRUE, toolchain_PATH_env_var()) + }) +}) + 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_error( - check_rtools4x_windows_toolchain(), - "No C++ toolchain was found", - fixed = TRUE - ) + expect_snapshot(error = TRUE, check_rtools4x_windows_toolchain()) }) test_that("is_ucrt_toolchain() returns correct values for R versions", { From 47f23b8eada8b7255f91abf719b7f2abd30d1c58 Mon Sep 17 00:00:00 2001 From: jgabry Date: Thu, 6 Aug 2026 18:05:09 -0600 Subject: [PATCH 15/16] Compare Windows toolchain paths canonically --- tests/testthat/test-install.R | 74 ++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/tests/testthat/test-install.R b/tests/testthat/test-install.R index e957e409f..4babdfd9d 100644 --- a/tests/testthat/test-install.R +++ b/tests/testthat/test-install.R @@ -365,9 +365,14 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { 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( - result, - paste(c(fake_bin_dir, fake_cpp_dir), collapse = ";") + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath( + c(fake_bin_dir, fake_cpp_dir), + winslash = "/", + mustWork = TRUE + ) ) }) @@ -386,9 +391,14 @@ test_that("toolchain_PATH_env_var() uses RTOOLS40_HOME for R < 4.2", { 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( - result, - paste(c(fake_bin_dir, fake_cpp_dir), collapse = ";") + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath( + c(fake_bin_dir, fake_cpp_dir), + winslash = "/", + mustWork = TRUE + ) ) }) }) @@ -439,22 +449,26 @@ test_that("toolchain_PATH_env_var() uses configured R_TOOLS_SOFT", { 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:", 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( - c( - paste0("repaired:", fake_bin_dir), - paste0("repaired:", fake_cpp_dir) - ), - collapse = ";" - ) + paste(paste0("repaired:", expected_dirs), collapse = ";") ) }) }) @@ -488,7 +502,11 @@ test_that("toolchain_PATH_env_var() falls back to Sys.which() when Rcmd fails", ) withr::local_envvar(c(PATH = fake_bin)) expect_no_warning(result <- toolchain_PATH_env_var()) - expect_identical(result, fake_bin) + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath(fake_bin, winslash = "/", mustWork = TRUE) + ) }) }) @@ -525,7 +543,11 @@ test_that("toolchain_PATH_env_var() searches PATH when R_TOOLS_SOFT is empty", { result <- toolchain_PATH_env_var() expect_identical(file_exists_calls, character()) expect_identical(which_calls, c("make", "c++")) - expect_identical(result, fake_bin) + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath(fake_bin, winslash = "/", mustWork = TRUE) + ) }) }) @@ -609,7 +631,11 @@ test_that("toolchain_PATH_env_var() falls back to PATH when executables missing ) withr::local_envvar(c(PATH = fake_bin)) result <- toolchain_PATH_env_var() - expect_identical(result, fake_bin) + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] + expect_identical( + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath(fake_bin, winslash = "/", mustWork = TRUE) + ) }) }) @@ -646,9 +672,14 @@ test_that("toolchain_PATH_env_var() preserves configured compiler", { .package = "base" ) result <- toolchain_PATH_env_var() + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] expect_identical( - result, - paste(c(fake_path_dir, fake_cpp_dir), collapse = ";") + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath( + c(fake_path_dir, fake_cpp_dir), + winslash = "/", + mustWork = TRUE + ) ) }) }) @@ -686,9 +717,14 @@ test_that("toolchain_PATH_env_var() preserves configured make", { .package = "base" ) result <- toolchain_PATH_env_var() + result_dirs <- strsplit(result, ";", fixed = TRUE)[[1]] expect_identical( - result, - paste(c(fake_bin_dir, fake_path_dir), collapse = ";") + normalizePath(result_dirs, winslash = "/", mustWork = TRUE), + normalizePath( + c(fake_bin_dir, fake_path_dir), + winslash = "/", + mustWork = TRUE + ) ) }) }) From 202976f03a3924abdccd292e4f10ff54d8a5d5e3 Mon Sep 17 00:00:00 2001 From: jgabry Date: Thu, 6 Aug 2026 20:46:25 -0600 Subject: [PATCH 16/16] Skip direct Rtools path check on Windows ARM --- .github/workflows/R-CMD-check.yaml | 2 +- DESCRIPTION | 2 +- R/install.R | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 6df4f2d39..276ed6a62 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -87,7 +87,7 @@ jobs: shell: pwsh - name: Verify R_TOOLS_SOFT configuration - if: ${{ runner.os == 'Windows' && matrix.config.r != '4.1' }} + if: ${{ matrix.config.os == 'windows-latest' && matrix.config.r != '4.1' }} run: | rtools_soft <- suppressWarnings( tools::Rcmd(c("config", "R_TOOLS_SOFT"), stdout = TRUE) 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/R/install.R b/R/install.R index 59a280e50..635560d55 100644 --- a/R/install.R +++ b/R/install.R @@ -772,7 +772,6 @@ toolchain_PATH_env_var <- function() { .cmdstanr$TOOLCHAIN_PATH <- paste(toolchain_dirs, collapse = ";") } - # Leave failed lookups uncached so later toolchain or PATH changes are found .cmdstanr$TOOLCHAIN_PATH }