diff --git a/NAMESPACE b/NAMESPACE index fd39219..cba42b2 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -8,6 +8,7 @@ export(assert_macos_supported) export(assert_r_version_supported) export(assert_x86_64) export(binary_download) +export(developer_dir) export(gfortran_install) export(gfortran_uninstall) export(gfortran_update) @@ -21,6 +22,7 @@ export(is_gfortran_installed) export(is_openmp_installed) export(is_xcode_app_installed) export(is_xcode_cli_installed) +export(is_xcode_toolchain_usable) export(macos_rtools_install) export(macos_rtools_uninstall) export(openmp_install) @@ -34,8 +36,10 @@ export(xcode_cli_path) export(xcode_cli_reset) export(xcode_cli_switch) export(xcode_cli_uninstall) +export(xcode_cli_version) export(xcode_select) export(xcode_select_path) export(xcode_select_version) +export(xcode_toolchains) export(xcodebuild) export(xcodebuild_version) diff --git a/NEWS.md b/NEWS.md index 29f61e7..08a9ece 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,60 +1,69 @@ # macrtools 0.0.8 -## Features +## Breaking changes -- Added support for macOS Golden Gate (27.x). +- `is_xcode_cli_installed()` now reports whether the Command Line Tools package + is installed, rather than whether it is the selected developer directory. Use + `developer_dir()` or `is_xcode_toolchain_usable()` to ask about the selection. -## Bug Fixes +- `is_xcode_app_installed()` now reports whether any Xcode is installed + anywhere, rather than whether `/Applications/Xcode.app` is selected. -- Fixed the macOS version check so that macOS 27 is recognized as supported. - `is_macos_r_supported()` compares against an *exclusive* upper bound, so the - previous value of `"27.0"` rejected every macOS 27 release, including 27.0 - itself. On macOS 27 this aborted `macos_rtools_install()`, `gfortran_install()` - and `openmp_install()`, and printed an "unsupported" warning on attach. - This is the same off-by-one that affected Tahoe - ([#28](https://github.com/coatless-mac/macrtools/issues/28)). - -- Fixed macOS version comparisons for `.0` releases. `sw_vers -productVersion` - reports only two components for a `.0` release (macOS 27.0 reports `"27.0"`, - never `"27.0.0"`), and `utils::compareVersion()` ranks a shorter string below - an otherwise-equal longer one. Comparing those against three-component bounds - placed every `.0` release in the *previous* release's range: `"12.0"` was - reported as Big Sur rather than Monterey, `"11.0"` matched nothing at all, and - `"10.13"` was rejected as unsupported by an error message that named 10.13 as - supported. Versions and bounds are now padded to three components before - comparison. +- `xcode_cli_switch()` takes `path` as its first argument, where `password` + used to be. -## Internal +## New features + +- Added support for macOS 27 (Golden Gate). + +- `xcode_toolchains()` lists every developer directory `xcode-select` can be + pointed at, with its version and whether it is active. + +- `is_xcode_toolchain_usable()` reports whether a compiler is reachable through + the active developer directory, which is what decides whether R can build a + package. + +- `developer_dir()` resolves the active developer directory, honoring + `DEVELOPER_DIR`. + +- `xcode_cli_version()` reports the installed Command Line Tools version. + +- `xcode_cli_switch()` gained a `path` argument, so a toolchain from + `xcode_toolchains()` can be selected instead of always forcing the Command + Line Tools. + +## Bug fixes + +- Xcode is detected outside its default location. Both predicates compared the + active developer directory against a hardcoded path, so a versioned bundle + such as `/Applications/Xcode_16.4.app` reported nothing installed and + triggered a needless `softwareupdate` run. + +- macOS 27 is recognized as supported. The version ceiling is exclusive, so the + previous bound rejected every 27.x release and aborted + `macos_rtools_install()`, `gfortran_install()` and `openmp_install()`. + +- macOS versions ending in `.0` compare correctly. `sw_vers` reports only two + components for those releases, which placed each one in the previous + release's range: `"12.0"` read as Big Sur, `"11.0"` matched nothing, and + `"10.13"` was rejected as unsupported. + +- `xcode_cli_install()` no longer skips installation when an unselected Xcode + exists elsewhere on disk, since it provides no compiler. + +- `macos_rtools_install()` reports which toolchain is active instead of assuming + a full Xcode, and reports a failure rather than success when nothing can + compile. + +- `macos_rtools_uninstall()` no longer reports a component as removed when it + was not installed. + +- The Command Line Tools version no longer comes from `xcode-select --version`, + which reports the version of `xcode-select` itself. -- Centralized the macOS support window behind `minimum_supported_macos_version()` - and `first_unsupported_macos_version()`, mirroring the R version window added - in 0.0.7. The ceiling is now named for what it is, the first *rejected* - version, which is the distinction both previous off-by-one bugs got wrong. -- The supported range shown by `assert_macos_supported()` and the startup - message is now rendered by a single `macos_support_range()` helper that - derives the version number from the bound. Previously the range was written - out as a literal in two files, with different wording, and neither was tested. -- Added upper-bound tests for `is_macos_r_supported()`. The suite previously - exercised only the lower bound, which is why both off-by-one bugs shipped - without a test failure. Added a test asserting that exactly one named - `is_macos_*()` predicate matches any given release, so a new release cannot be - swallowed by its predecessor's range. -- Added `is_macos_golden_gate()` for 27.x. -- Added `pad_version()` to normalize version strings before comparison, and - extended the predicate test to cover the two-component `.0` form that - `sw_vers` actually emits. -- Corrected the `recipes_binary_install()` documentation, which stated that the - `darwin` repository is chosen by comparing against the macOS version. It is - chosen by comparing against the Darwin kernel version, and the two are no - longer a fixed offset apart: Darwin stayed on 25 for macOS 26, then realigned - to 27 for macOS 27, skipping 26. -- Noted in `R/openmp.R` that Apple renumbered Apple clang from 17.0.0 - (`clang-1700.x`) to 21.0.0 (`clang-2100.x`) at Xcode 26.4, so Xcode 26.4 and - newer extrapolate onto the LLVM OpenMP 19.1.5 row. Upstream documents that - runtime only through Xcode 26.3, but 19.1.5 remains the newest build published - on mac.r-project.org, so the selection is unchanged and still correct. -- Bumped the declared `testthat` minimum to 3.1.7, the version that introduced - `local_mocked_bindings()`, which the suite already relies on throughout. +- `recipes_binary_install()` documentation now states that the `darwin` + repository is chosen by Darwin kernel version, not macOS version. The two are + no longer a fixed offset apart. # macrtools 0.0.7 diff --git a/R/toolchain.R b/R/toolchain.R index c99d466..165dee4 100644 --- a/R/toolchain.R +++ b/R/toolchain.R @@ -134,61 +134,112 @@ rtools_install_xcode_cli <- function(entered_password, verbose, describe_steps, cli::cli_text("") # Add spacing result_xcode <- TRUE - if(!is_xcode_app_installed()) { - if(!is_xcode_cli_installed()) { - if (verbose) { - cli::cli_progress_update(id = pb_id, set = 10) - cli::cli_alert_info("{.pkg macrtools}: Need to install Xcode Command Line Tools.") - cli::cli_bullets(c( - "Source: Apple Software Update", - "Installation method: softwareupdate command", - "Status: Not installed", - "Estimated time: 10-15 minutes" - )) - cli::cli_text("") # Add spacing - } + # Gate on whether a compiler is actually REACHABLE. The previous check + # compared the active developer directory against two hardcoded paths and + # so misread every non-standard layout, including the versioned Xcode that + # every GitHub runner selects, as "no toolchain installed". + if(is_xcode_toolchain_usable()) { + if(describe_steps) { + active_dir <- developer_dir() - if (verbose) cli::cli_progress_update(id = pb_id, set = 20) - result_xcode <- xcode_cli_install(password = entered_password, verbose = describe_steps) + if(developer_dir_is_app(active_dir)) { + bundle <- base::sub("/Contents/Developer$", "", active_dir) + active_version <- xcode_app_version(bundle) - if(!result_xcode) { - cli::cli_abort(c( - "{.pkg macrtools}: Failed to install Xcode Command Line Tools.", - "This is a required component for R package development.", - "Installation status: Failed", - "i" = "Try installing manually by running 'sudo xcode-select --install' in Terminal." + cli::cli_alert_info("{.pkg macrtools}: Full Xcode.app IDE is active.") + cli::cli_bullets(c( + "Location: {.path {bundle}}", + "Version: {.val {active_version}}", + "Status: Pre-installed, skipping Command Line Tools installation" )) - } - - if (verbose) cli::cli_progress_update(id = pb_id, set = 30) - } else { - if(describe_steps) { - # Get Xcode CLI version information - xcode_version <- exec_text('xcode-select', '--version') + } else { + active_location <- if(base::is.na(active_dir)) { + install_directory_xcode_cli() + } else { + active_dir + } + # xcode-select --version reports the version of xcode-select + # itself and never changes with the toolchain, so read the + # package receipt instead. + active_version <- xcode_cli_version() cli::cli_alert_info("{.pkg macrtools}: Xcode Command Line Tools already installed.") cli::cli_bullets(c( - "Location: {.path {xcode_cli_path()}}", - "Version: {.val {xcode_version}}", + "Location: {.path {active_location}}", + "Version: {.val {active_version}}", "Status: Pre-installed, no action needed" )) - cli::cli_text("") # Add spacing } - if (verbose) cli::cli_progress_update(id = pb_id, set = 30) + cli::cli_text("") # Add spacing + } + if (verbose) cli::cli_progress_update(id = pb_id, set = 30) + } else if(!is_xcode_cli_installed()) { + if (verbose) { + cli::cli_progress_update(id = pb_id, set = 10) + cli::cli_alert_info("{.pkg macrtools}: Need to install Xcode Command Line Tools.") + cli::cli_bullets(c( + "Source: Apple Software Update", + "Installation method: softwareupdate command", + "Status: Not installed", + "Estimated time: 10-15 minutes" + )) + cli::cli_text("") # Add spacing } - } else { - if(describe_steps) { - # Get full Xcode app version information - xcode_app_info <- exec_text('xcodebuild', '-version') - cli::cli_alert_info("{.pkg macrtools}: Full Xcode.app IDE is installed.") + if (verbose) cli::cli_progress_update(id = pb_id, set = 20) + result_xcode <- xcode_cli_install(password = entered_password, verbose = describe_steps) + + if(!result_xcode) { + cli::cli_abort(c( + "{.pkg macrtools}: Failed to install Xcode Command Line Tools.", + "This is a required component for R package development.", + "Installation status: Failed", + "i" = "Try installing manually by running 'sudo xcode-select --install' in Terminal." + )) + } + + # Installing does not guarantee the tools became REACHABLE: a stale + # selection or a DEVELOPER_DIR override still shadows them. Verify + # rather than trusting the installer's return value, so the summary + # cannot claim a working toolchain on a machine that cannot compile. + if(!is_xcode_toolchain_usable()) { + active_dir <- developer_dir() + selected <- if(base::is.na(active_dir)) "none selected" else active_dir + + cli::cli_alert_warning(base::paste( + "{.pkg macrtools}: Xcode Command Line Tools were installed, but", + "no compiler is reachable yet." + )) cli::cli_bullets(c( - "Location: {.path {'/Applications/Xcode.app'}}", - "Version information: {.val {xcode_app_info}}", - "Status: Pre-installed, skipping Command Line Tools installation" + "Active developer directory: {.val {selected}}", + "i" = "Run {.code macrtools::xcode_cli_switch()} to select them, then retry." )) cli::cli_text("") # Add spacing + + result_xcode <- FALSE } + + if (verbose) cli::cli_progress_update(id = pb_id, set = 30) + } else { + # The Command Line Tools are on disk but no compiler is reachable, so + # the active developer directory is stale or broken. Reinstalling does + # not fix a bad selection, and reporting success here would claim a + # working toolchain on a machine that cannot compile. + active_dir <- developer_dir() + selected <- if(base::is.na(active_dir)) "none selected" else active_dir + + cli::cli_alert_warning(base::paste( + "{.pkg macrtools}: Xcode Command Line Tools are installed, but no", + "compiler is reachable." + )) + cli::cli_bullets(c( + "Command Line Tools: {.path {install_directory_xcode_cli()}}", + "Active developer directory: {.val {selected}}", + "i" = "Run {.code macrtools::xcode_cli_switch()} to select them, then retry." + )) + cli::cli_text("") # Add spacing + + result_xcode <- FALSE if (verbose) cli::cli_progress_update(id = pb_id, set = 30) } @@ -359,13 +410,15 @@ macos_rtools_uninstall <- function( # Step 1: Uninstall Xcode CLI result_xcode <- TRUE + removed_xcode <- FALSE if(is_xcode_cli_installed()) { if (verbose) { cli::cli_progress_update(id = pb_id, set = 30) cli::cli_alert_info("{.pkg macrtools}: Uninstalling Xcode CLI...") cli::cli_text("") # Add spacing } - result_xcode <- xcode_cli_uninstall(password = password, verbose = verbose) + removed_xcode <- xcode_cli_uninstall(password = password, verbose = verbose) + result_xcode <- removed_xcode if(!result_xcode) { cli::cli_abort("{.pkg macrtools}: Failed to uninstall Xcode CLI. Please see manual steps.") } @@ -379,12 +432,14 @@ macos_rtools_uninstall <- function( # Step 2: Uninstall gfortran if (verbose) cli::cli_progress_update(id = pb_id, set = 70) result_gfortran <- TRUE + removed_gfortran <- FALSE if(is_gfortran_installed()) { if (verbose) { cli::cli_alert_info("{.pkg macrtools}: Uninstalling gfortran...") cli::cli_text("") # Add spacing } - result_gfortran <- gfortran_uninstall(password = password, verbose = verbose) + removed_gfortran <- gfortran_uninstall(password = password, verbose = verbose) + result_gfortran <- removed_gfortran if(!result_gfortran) { cli::cli_abort("{.pkg macrtools}: Failed to uninstall gfortran. Please see manual steps.") } @@ -405,8 +460,8 @@ macos_rtools_uninstall <- function( if(clean) { cli::cli_alert_success("{.pkg macrtools}: Uninstallation complete.") cli::cli_bullets(c( - "Xcode CLI: Successfully removed", - "Gfortran: Successfully removed", + base::paste0("Xcode CLI: ", if (removed_xcode) "Successfully removed" else "Was not installed"), + base::paste0("Gfortran: ", if (removed_gfortran) "Successfully removed" else "Was not installed"), "Note: This did not uninstall any binaries from the recipes project" )) } diff --git a/R/xcode.R b/R/xcode.R index fbb4f60..4756d5b 100644 --- a/R/xcode.R +++ b/R/xcode.R @@ -58,25 +58,20 @@ xcodebuild_version <- function() { #' Detect if the Xcode.app IDE is Installed #' -#' Checks to see whether Xcode.app Integrated Developer Environment -#' (IDE) was installed and is active under the default location. +#' Checks whether an Xcode.app Integrated Developer Environment (IDE) is +#' installed anywhere on the system. #' #' @details +#' Installed, not *selected*. Xcode is routinely installed under a versioned +#' name such as `/Applications/Xcode_26.6.app`, which is what every +#' GitHub-hosted macOS runner does, and `man xcode-select` documents +#' non-default locations as supported. Bundles are therefore located by bundle +#' identifier rather than by path, so a renamed or relocated install is still +#' found. #' -#' Checks to see if Xcode.app IDE is active by running: -#' -#' ```sh -#' xcode-select -p -#' ``` -#' -#' If it returns: -#' -#' ```sh -#' /Applications/Xcode.app/Contents/Developer -#' ``` -#' -#' We consider the full Xcode.app to be **installed** and **selected** as -#' the command line tools to use. +#' To ask instead which toolchain is *active*, use [developer_dir()]; to ask +#' whether a compiler can actually be reached, use +#' [is_xcode_toolchain_usable()]. #' #' @rdname xcode-app-ide #' @export @@ -86,12 +81,181 @@ xcodebuild_version <- function() { is_xcode_app_installed <- function() { assert_mac() - # Check to see if the Xcode path is set - path_info <- xcode_select_path() + base::length(xcode_app_bundles()) > 0L +} + +#' Normalize a Developer Directory Path +#' +#' @details +#' `xcode-select --switch` accepts either an `Xcode.app` bundle or a developer +#' directory inside one, so both spellings are normalized to the developer +#' directory. Trailing slashes and symlinks are resolved too, because macOS +#' reports paths in forms that [base::identical()] will never match. +#' +#' @param path A bundle path or developer directory. +#' @return The normalized developer directory. +#' @keywords internal +normalize_developer_dir <- function(path) { + path <- base::sub("/+$", "", path) + + is_bundle <- base::grepl("\\.app$", path) + path[is_bundle] <- base::file.path(path[is_bundle], "Contents", "Developer") + + base::normalizePath(path, mustWork = FALSE) +} + +#' Inspect the Active Xcode Toolchain +#' +#' Report which developer directory is active, whether a compiler can actually +#' be reached through it, and which toolchains are available to switch to. +#' +#' @details +#' `developer_dir()` resolves the active developer directory. +#' `DEVELOPER_DIR` overrides the selection stored by `xcode-select`, for +#' `xcrun`, the `/usr/bin` shims and `xcodebuild` alike, so it is consulted +#' first. Note that `xcode-select --print-path` echoes its value and exits `0` +#' even when the directory does not exist, so the exit status alone proves +#' nothing; the path is verified before being returned. +#' +#' @return The active developer directory, or `NA_character_` when nothing +#' usable is selected. +#' @name xcode-toolchain +#' @rdname xcode-toolchain +#' @export +#' @examples +#' # Which developer directory is active +#' developer_dir() +developer_dir <- function() { + from_env <- base::Sys.getenv("DEVELOPER_DIR", unset = "") + + candidate <- if (base::nzchar(from_env)) { + from_env + } else { + # xcode-select may be missing entirely, so failing to run it must + # resolve to "nothing selected" rather than propagating an error. + path_info <- base::tryCatch(xcode_select_path(), error = function(e) NULL) + if (!base::is.null(path_info) && base::identical(path_info$status, 0L)) { + path_info$output + } else { + "" + } + } + + if (base::length(candidate) != 1L || base::is.na(candidate) || + !base::nzchar(candidate)) { + return(NA_character_) + } + + candidate <- normalize_developer_dir(candidate) + + if (!base::dir.exists(candidate)) NA_character_ else candidate +} + +#' Locate Every Installed Xcode Bundle +#' +#' @details +#' Xcode is routinely installed under a versioned name, which is what every +#' GitHub-hosted macOS runner does (`/Applications/Xcode_26.6.app`), and +#' `man xcode-select` documents non-default locations as supported. Spotlight +#' is queried by bundle identifier so the name and location do not matter, with +#' a glob fallback for machines where Spotlight indexing is disabled. Time +#' Machine copies are discarded. +#' +#' @return Paths to the installed Xcode bundles, possibly empty. +#' @keywords internal +xcode_app_bundles <- function() { + from_spotlight <- exec_text( + "mdfind", + "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'", + fallback = base::character(0) + ) + + candidates <- base::unique(c( + from_spotlight, + base::Sys.glob("/Applications/Xcode*.app") + )) + + candidates <- candidates[!base::is.na(candidates) & base::nzchar(candidates)] + candidates <- candidates[ + !base::grepl("/Backups.backupdb/", candidates, fixed = TRUE) + ] + candidates <- candidates[ + base::dir.exists(base::file.path(candidates, "Contents", "Developer")) + ] + + base::unique(base::normalizePath(candidates, mustWork = FALSE)) +} + +#' Version of an Xcode Bundle +#' +#' @param bundle Path to an `Xcode.app` bundle. +#' @return The bundle's short version string, or `NA_character_`. +#' @keywords internal +xcode_app_version <- function(bundle) { + plist <- base::file.path(bundle, "Contents", "version.plist") + + if (!base::file.exists(plist)) { + return(NA_character_) + } + + out <- exec_text( + "/usr/libexec/PlistBuddy", + c("-c", "Print :CFBundleShortVersionString", plist), + fallback = NA_character_ + ) + + if (base::length(out) != 1L || base::is.na(out) || !base::nzchar(out)) { + NA_character_ + } else { + out + } +} + +#' Check if a Developer Directory is a Full Xcode +#' +#' @details +#' A full Xcode carries the platform bundles; the Command Line Tools do not. +#' This distinguishes the two without shelling out to `xcodebuild`, which is +#' slow, errors when the Command Line Tools are active, and can stall on the +#' license agreement. +#' +#' @param dir Developer directory to test, defaulting to the active one. +#' @return TRUE if `dir` is a full Xcode developer directory, FALSE otherwise +#' @keywords internal +developer_dir_is_app <- function(dir = developer_dir()) { + !base::is.na(dir) && + base::dir.exists(base::file.path(dir, "Platforms", "MacOSX.platform")) +} + +#' Check if the Active Toolchain Can Actually Compile +#' +#' @details +#' This is the question that matters for building R packages. R is configured +#' with a bare `clang`, which resolves to the `/usr/bin` shim, so whether a +#' package can be compiled depends on the active developer directory +#' dispatching successfully, not on where the tools live. +#' +#' `xcrun --find clang` resolves through the active directory and honors +#' `DEVELOPER_DIR`. It can, however, exit `0` while printing an error for a +#' degraded bundle, so the returned path is confirmed to exist and be +#' executable rather than trusting the exit status. +#' +#' @return TRUE if a usable compiler is reachable, FALSE otherwise +#' @rdname xcode-toolchain +#' @export +#' @examples +#' # Check whether a compiler is actually reachable +#' is_xcode_toolchain_usable() +is_xcode_toolchain_usable <- function() { + assert_mac() + + found <- exec_text("xcrun", c("--find", "clang"), fallback = NA_character_) + + if (base::length(found) != 1L || base::is.na(found) || !base::nzchar(found)) { + return(FALSE) + } - base::identical(path_info$status, 0L) && - base::identical(path_info$output, install_directory_xcode_app()) && - base::dir.exists(path_info$output) + base::file.exists(found) && base::identical(base::file.access(found, 1L)[[1]], 0L) } #' Find, Install, or Uninstall XCode CLI @@ -101,13 +265,20 @@ is_xcode_app_installed <- function() { #' #' @section Check if XCode CLI is installed: #' -#' Checks to see if Xcode CLI returns a viable path to the default Xcode CLI -#' location by checking the output of: +#' `is_xcode_cli_installed()` reports whether the Command Line Tools *package* +#' is present, by looking for the compiler it installs: #' #' ```sh -#' xcode-select -p +#' /Library/Developer/CommandLineTools/usr/bin/clang #' ``` #' +#' This is independent of which developer directory is selected. The previous +#' implementation compared `xcode-select -p` against that path with +#' `identical()`, so it reported `FALSE` on every machine where anything else +#' was active, including the versioned Xcode that CI runners select. +#' +#' For the selection, see [developer_dir()] and [is_xcode_toolchain_usable()]. +#' #' @rdname xcode-cli #' @export #' @examples @@ -116,11 +287,101 @@ is_xcode_app_installed <- function() { is_xcode_cli_installed <- function() { assert_mac() - path_info <- xcode_select_path() + base::file.exists( + base::file.path(install_directory_xcode_cli(), "usr", "bin", "clang") + ) +} + +#' @return The Command Line Tools version from the package receipt, or +#' `NA_character_` when they are not installed. +#' @rdname xcode-cli +#' @export +#' @examples +#' # Version of the installed Command Line Tools +#' xcode_cli_version() +xcode_cli_version <- function() { + if (!is_xcode_cli_installed()) { + return(NA_character_) + } + + info <- exec_text( + "pkgutil", + "--pkg-info=com.apple.pkg.CLTools_Executables", + fallback = NA_character_ + ) + + version_line <- base::grep("^version:", info, value = TRUE) + + if (base::length(version_line) == 0L) { + return(NA_character_) + } + + base::trimws(base::sub("^version:", "", version_line[[1]])) +} + +#' @section Listing the Available Toolchains: +#' +#' `xcode_toolchains()` reports every developer directory `xcode-select` could +#' be pointed at: the Command Line Tools, plus each installed Xcode bundle +#' wherever it lives. Pass a `path` from this table to [xcode_cli_switch()]. +#' +#' Note that this lists *developer directories*, not `.xctoolchain` bundles. +#' Those are a separate axis selected through `xcrun --toolchain` and the +#' `TOOLCHAINS` variable, and are mostly Swift snapshots. +#' +#' @return A data frame with one row per selectable developer directory and +#' columns `path`, `type` (`"cli"` or `"app"`), `version` and `active`. +#' @rdname xcode-toolchain +#' @export +#' @examples +#' # List every toolchain xcode-select could use +#' xcode_toolchains() +xcode_toolchains <- function() { + assert_mac() + + empty <- base::data.frame( + path = base::character(0), + type = base::character(0), + version = base::character(0), + active = base::logical(0), + stringsAsFactors = FALSE + ) + + rows <- base::list() + + if (is_xcode_cli_installed()) { + rows[[base::length(rows) + 1L]] <- base::data.frame( + path = install_directory_xcode_cli(), + type = "cli", + version = xcode_cli_version(), + stringsAsFactors = FALSE + ) + } + + for (bundle in xcode_app_bundles()) { + rows[[base::length(rows) + 1L]] <- base::data.frame( + path = base::file.path(bundle, "Contents", "Developer"), + type = "app", + version = xcode_app_version(bundle), + stringsAsFactors = FALSE + ) + } + + if (base::length(rows) == 0L) { + return(empty) + } + + out <- base::do.call(base::rbind, rows) - base::identical(path_info$status, 0L) && - base::identical(path_info$output, install_directory_xcode_cli()) && - base::dir.exists(path_info$output) + # developer_dir() is normalized, so normalize this side too rather than + # relying on the literals happening to already be in canonical form. + active <- developer_dir() + out$active <- !base::is.na(active) & + normalize_developer_dir(out$path) == active + + out <- out[base::order(out$type, out$path), , drop = FALSE] + base::row.names(out) <- NULL + out } #' @rdname xcode-cli @@ -211,10 +472,16 @@ xcode_cli_install <- function(password = base::getOption("macrtools.password"), return(base::invisible(TRUE)) } - if (base::isTRUE(is_xcode_app_installed())) { + # Skipping requires a toolchain that is actually ACTIVE. An Xcode sitting + # on disk unselected provides no compiler, so is_xcode_app_installed(), + # which now means "installed anywhere", must not gate this. + if (base::isTRUE(is_xcode_toolchain_usable())) { if(verbose) { - cli::cli_alert_info("{.pkg macrtools}: Xcode.app IDE is installed.") - cli::cli_text("Skipping the commandline installation.") + cli::cli_alert_info("{.pkg macrtools}: A usable Xcode toolchain is already active.") + cli::cli_bullets(c( + "Location: {.path {developer_dir()}}", + "Skipping the commandline installation." + )) cli::cli_text("") # Add spacing } return(base::invisible(TRUE)) @@ -322,26 +589,26 @@ xcode_cli_available_label <- function() { xcode_cli_uninstall <- function(password = base::getOption("macrtools.password"), verbose = TRUE){ assert_mac() + # Returns FALSE so callers cannot report a removal that never happened. if(base::isFALSE(is_xcode_cli_installed())) { if(verbose) { cli::cli_alert_info("{.pkg macrtools}: Xcode CLI is not installed.") cli::cli_text("") # Add spacing } - return(base::invisible(TRUE)) + return(base::invisible(FALSE)) } - xcli_path <- xcode_cli_path() - - # We should never hit this line of code as the is_xcode_cli_installed() now - # focuses on only CLI. But, we might want to change that. - if(xcli_path == install_directory_xcode_app()) { - cli::cli_alert_warning("{.pkg macrtools}: Full Xcode application detected.") - cli::cli_bullets(c( - "Path: {.path {xcode_cli_path()}}", - "Please uninstall it using the App Store or manually." + # Removing the Command Line Tools invalidates the selection if they are the + # active developer directory, so warn before, not after. + active_dir <- developer_dir() + if (!base::is.na(active_dir) && + base::identical(active_dir, normalize_developer_dir(install_directory_xcode_cli()))) { + cli::cli_alert_warning(base::paste( + "{.pkg macrtools}: The Command Line Tools are the active developer", + "directory. After removal, select another toolchain with", + "{.code xcode_cli_switch()}." )) cli::cli_text("") # Add spacing - return(base::invisible(TRUE)) } if (verbose) { @@ -373,24 +640,68 @@ xcode_cli_uninstall <- function(password = base::getOption("macrtools.password") #' @section Change Xcode CLI Location: -#' If the Xcode Application has been previously installed, the underlying -#' path reported by `xcode-select` may not reflect the Xcode CLI location. -#' The situation can be rectified by using the [xcode_cli_switch()] function, -#' which changes the command line tool directory away from the Xcode application -#' location to the Xcode CLI location. This uses the _default_ Xcode CLI -#' path. +#' The path reported by `xcode-select` may not point at the toolchain you want +#' to build against. [xcode_cli_switch()] changes it, defaulting to the +#' Command Line Tools: #' #' ```sh #' sudo xcode-select --switch /Library/Developer/CommandLineTools #' ``` #' +#' Pass `path` to select something else, using a `path` from +#' [xcode_toolchains()]. The target is validated first, and the selection being +#' replaced is named, since the change is machine-wide and affects every build. +#' +#' Note that `DEVELOPER_DIR` overrides the stored selection, so this has no +#' visible effect while that variable is set. +#' #' If this does not fix the issue, we recommend using the [xcode_cli_reset()] #' function. #' @export #' @rdname xcode-cli -xcode_cli_switch <- function(password = base::getOption("macrtools.password"), verbose = TRUE) { - # The path matches with the default install directory location. - if (xcode_cli_path() == install_directory_xcode_cli()) { +#' @param path Developer directory, or `Xcode.app` bundle, to make active. +#' Defaults to the Command Line Tools location. Use [xcode_toolchains()] to +#' see the available options. +xcode_cli_switch <- function(path = install_directory_xcode_cli(), + password = base::getOption("macrtools.password"), + verbose = TRUE) { + assert_mac() + + target <- normalize_developer_dir(path) + + # The value is deliberately NOT echoed. `path` used to be `password`, so a + # legacy positional call lands here carrying a sudo password. + if (!base::dir.exists(target) || + !base::dir.exists(base::file.path(target, "usr", "bin"))) { + cli::cli_abort(c( + "{.pkg macrtools}: The {.arg path} argument is not a developer directory.", + "i" = "Run {.code xcode_toolchains()} to see the available toolchains." + )) + } + + # DEVELOPER_DIR overrides the stored selection for xcrun and the /usr/bin + # shims, but `xcode-select --switch` writes the STORED selection. Comparing + # against the override would skip a switch that has not happened, so the + # stored value decides whether this is a no-op. + stored <- base::tryCatch(xcode_select_path(), error = function(e) NULL) + current <- if (!base::is.null(stored) && base::identical(stored$status, 0L) && + base::length(stored$output) == 1L && base::nzchar(stored$output)) { + normalize_developer_dir(stored$output) + } else { + NA_character_ + } + + if (base::nzchar(base::Sys.getenv("DEVELOPER_DIR", unset = "")) && verbose) { + cli::cli_alert_warning(base::paste( + "{.pkg macrtools}: {.envvar DEVELOPER_DIR} is set and overrides the", + "stored selection, so this change will not take effect until it is", + "unset." + )) + cli::cli_text("") # Add spacing + } + + # The target is already the stored selection. + if (!base::is.na(current) && base::identical(current, target)) { if (verbose) { cli::cli_alert_info("{.pkg macrtools}: Xcode CLI path is correctly set.") cli::cli_text("") # Add spacing @@ -398,17 +709,19 @@ xcode_cli_switch <- function(password = base::getOption("macrtools.password"), v return(base::invisible(TRUE)) } - # Need to modify the location to the current CLI path + # Switching away from a deliberately selected toolchain is machine-wide and + # affects every build, so say what is being displaced rather than doing it + # quietly. if (verbose) { cli::cli_alert_info("{.pkg macrtools}: Setting the Xcode CLI path.") cli::cli_bullets(c( - "Current path: {.path {xcode_cli_path()}}", - "Target path: {.path {install_directory_xcode_cli()}}" + "Current path: {.path {if (base::is.na(current)) '' else current}}", + "Target path: {.path {target}}" )) cli::cli_text("") # Add spacing } - cmd <- base::paste("xcode-select", "--switch", install_directory_xcode_cli()) + cmd <- base::paste("xcode-select", "--switch", base::shQuote(target)) # Change the directory xcli_switch_status <- shell_execute(cmd, diff --git a/README.Rmd b/README.Rmd index bf8521b..a591c62 100644 --- a/README.Rmd +++ b/README.Rmd @@ -17,7 +17,7 @@ knitr::opts_chunk$set( - macrtools hex logo + macrtools hex logo # macrtools diff --git a/README.md b/README.md index 6e13d48..7342159 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ - macrtools hex logo + macrtools hex logo # macrtools diff --git a/_pkgdown.yml b/_pkgdown.yml index 1093b00..9b9dea6 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -57,8 +57,12 @@ reference: - xcode_cli_switch - xcode_cli_reset - xcode_cli_path + - xcode_cli_version - is_xcode_cli_installed - is_xcode_app_installed + - developer_dir + - is_xcode_toolchain_usable + - xcode_toolchains - xcode_select - xcode_select_path - xcode_select_version diff --git a/man/developer_dir_is_app.Rd b/man/developer_dir_is_app.Rd new file mode 100644 index 0000000..956b2d6 --- /dev/null +++ b/man/developer_dir_is_app.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{developer_dir_is_app} +\alias{developer_dir_is_app} +\title{Check if a Developer Directory is a Full Xcode} +\usage{ +developer_dir_is_app(dir = developer_dir()) +} +\arguments{ +\item{dir}{Developer directory to test, defaulting to the active one.} +} +\value{ +TRUE if \code{dir} is a full Xcode developer directory, FALSE otherwise +} +\description{ +Check if a Developer Directory is a Full Xcode +} +\details{ +A full Xcode carries the platform bundles; the Command Line Tools do not. +This distinguishes the two without shelling out to \code{xcodebuild}, which is +slow, errors when the Command Line Tools are active, and can stall on the +license agreement. +} +\keyword{internal} diff --git a/man/normalize_developer_dir.Rd b/man/normalize_developer_dir.Rd new file mode 100644 index 0000000..9afc4a2 --- /dev/null +++ b/man/normalize_developer_dir.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{normalize_developer_dir} +\alias{normalize_developer_dir} +\title{Normalize a Developer Directory Path} +\usage{ +normalize_developer_dir(path) +} +\arguments{ +\item{path}{A bundle path or developer directory.} +} +\value{ +The normalized developer directory. +} +\description{ +Normalize a Developer Directory Path +} +\details{ +\code{xcode-select --switch} accepts either an \code{Xcode.app} bundle or a developer +directory inside one, so both spellings are normalized to the developer +directory. Trailing slashes and symlinks are resolved too, because macOS +reports paths in forms that \code{\link[base:identical]{base::identical()}} will never match. +} +\keyword{internal} diff --git a/man/xcode-app-ide.Rd b/man/xcode-app-ide.Rd index eb38f97..c23a6ec 100644 --- a/man/xcode-app-ide.Rd +++ b/man/xcode-app-ide.Rd @@ -7,22 +7,20 @@ is_xcode_app_installed() } \description{ -Checks to see whether Xcode.app Integrated Developer Environment -(IDE) was installed and is active under the default location. +Checks whether an Xcode.app Integrated Developer Environment (IDE) is +installed anywhere on the system. } \details{ -Checks to see if Xcode.app IDE is active by running: +Installed, not \emph{selected}. Xcode is routinely installed under a versioned +name such as \verb{/Applications/Xcode_26.6.app}, which is what every +GitHub-hosted macOS runner does, and \verb{man xcode-select} documents +non-default locations as supported. Bundles are therefore located by bundle +identifier rather than by path, so a renamed or relocated install is still +found. -\if{html}{\out{
}}\preformatted{xcode-select -p -}\if{html}{\out{
}} - -If it returns: - -\if{html}{\out{
}}\preformatted{/Applications/Xcode.app/Contents/Developer -}\if{html}{\out{
}} - -We consider the full Xcode.app to be \strong{installed} and \strong{selected} as -the command line tools to use. +To ask instead which toolchain is \emph{active}, use \code{\link[=developer_dir]{developer_dir()}}; to ask +whether a compiler can actually be reached, use +\code{\link[=is_xcode_toolchain_usable]{is_xcode_toolchain_usable()}}. } \examples{ # Check if Xcode.app IDE is on the path diff --git a/man/xcode-cli.Rd b/man/xcode-cli.Rd index 7b6b123..11dc1d2 100644 --- a/man/xcode-cli.Rd +++ b/man/xcode-cli.Rd @@ -2,6 +2,7 @@ % Please edit documentation in R/xcode.R \name{is_xcode_cli_installed} \alias{is_xcode_cli_installed} +\alias{xcode_cli_version} \alias{xcode_cli_path} \alias{xcode_cli_install} \alias{xcode_cli_uninstall} @@ -11,6 +12,8 @@ \usage{ is_xcode_cli_installed() +xcode_cli_version() + xcode_cli_path() xcode_cli_install( @@ -24,6 +27,7 @@ xcode_cli_uninstall( ) xcode_cli_switch( + path = install_directory_xcode_cli(), password = base::getOption("macrtools.password"), verbose = TRUE ) @@ -37,6 +41,14 @@ xcode_cli_reset( \item{password}{User password to access \code{sudo}.} \item{verbose}{Display status messages} + +\item{path}{Developer directory, or \code{Xcode.app} bundle, to make active. +Defaults to the Command Line Tools location. Use \code{\link[=xcode_toolchains]{xcode_toolchains()}} to +see the available options.} +} +\value{ +The Command Line Tools version from the package receipt, or +\code{NA_character_} when they are not installed. } \description{ Set of functions that seek to identify whether XCode CLI was installed, @@ -45,11 +57,18 @@ allow XCode CLI to be installed, and removing XCode CLI. \section{Check if XCode CLI is installed}{ -Checks to see if Xcode CLI returns a viable path to the default Xcode CLI -location by checking the output of: +\code{is_xcode_cli_installed()} reports whether the Command Line Tools \emph{package} +is present, by looking for the compiler it installs: -\if{html}{\out{
}}\preformatted{xcode-select -p +\if{html}{\out{
}}\preformatted{/Library/Developer/CommandLineTools/usr/bin/clang }\if{html}{\out{
}} + +This is independent of which developer directory is selected. The previous +implementation compared \code{xcode-select -p} against that path with +\code{identical()}, so it reported \code{FALSE} on every machine where anything else +was active, including the versioned Xcode that CI runners select. + +For the selection, see \code{\link[=developer_dir]{developer_dir()}} and \code{\link[=is_xcode_toolchain_usable]{is_xcode_toolchain_usable()}}. } \section{XCode CLI Installation}{ @@ -142,16 +161,20 @@ app using the following steps: \section{Change Xcode CLI Location}{ -If the Xcode Application has been previously installed, the underlying -path reported by \code{xcode-select} may not reflect the Xcode CLI location. -The situation can be rectified by using the \code{\link[=xcode_cli_switch]{xcode_cli_switch()}} function, -which changes the command line tool directory away from the Xcode application -location to the Xcode CLI location. This uses the \emph{default} Xcode CLI -path. +The path reported by \code{xcode-select} may not point at the toolchain you want +to build against. \code{\link[=xcode_cli_switch]{xcode_cli_switch()}} changes it, defaulting to the +Command Line Tools: \if{html}{\out{
}}\preformatted{sudo xcode-select --switch /Library/Developer/CommandLineTools }\if{html}{\out{
}} +Pass \code{path} to select something else, using a \code{path} from +\code{\link[=xcode_toolchains]{xcode_toolchains()}}. The target is validated first, and the selection being +replaced is named, since the change is machine-wide and affects every build. + +Note that \code{DEVELOPER_DIR} overrides the stored selection, so this has no +visible effect while that variable is set. + If this does not fix the issue, we recommend using the \code{\link[=xcode_cli_reset]{xcode_cli_reset()}} function. } @@ -170,6 +193,8 @@ We use an \emph{R} sanitized \emph{shell} version of: \examples{ # Check if Xcode CLI is installed is_xcode_cli_installed() +# Version of the installed Command Line Tools +xcode_cli_version() # Determine the path location of Xcode CLI xcode_cli_path() } diff --git a/man/xcode-toolchain.Rd b/man/xcode-toolchain.Rd new file mode 100644 index 0000000..bf504d7 --- /dev/null +++ b/man/xcode-toolchain.Rd @@ -0,0 +1,66 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{xcode-toolchain} +\alias{xcode-toolchain} +\alias{developer_dir} +\alias{is_xcode_toolchain_usable} +\alias{xcode_toolchains} +\title{Inspect the Active Xcode Toolchain} +\usage{ +developer_dir() + +is_xcode_toolchain_usable() + +xcode_toolchains() +} +\value{ +The active developer directory, or \code{NA_character_} when nothing +usable is selected. + +TRUE if a usable compiler is reachable, FALSE otherwise + +A data frame with one row per selectable developer directory and +columns \code{path}, \code{type} (\code{"cli"} or \code{"app"}), \code{version} and \code{active}. +} +\description{ +Report which developer directory is active, whether a compiler can actually +be reached through it, and which toolchains are available to switch to. +} +\details{ +\code{developer_dir()} resolves the active developer directory. +\code{DEVELOPER_DIR} overrides the selection stored by \code{xcode-select}, for +\code{xcrun}, the \verb{/usr/bin} shims and \code{xcodebuild} alike, so it is consulted +first. Note that \code{xcode-select --print-path} echoes its value and exits \code{0} +even when the directory does not exist, so the exit status alone proves +nothing; the path is verified before being returned. + +This is the question that matters for building R packages. R is configured +with a bare \code{clang}, which resolves to the \verb{/usr/bin} shim, so whether a +package can be compiled depends on the active developer directory +dispatching successfully, not on where the tools live. + +\verb{xcrun --find clang} resolves through the active directory and honors +\code{DEVELOPER_DIR}. It can, however, exit \code{0} while printing an error for a +degraded bundle, so the returned path is confirmed to exist and be +executable rather than trusting the exit status. +} +\section{Listing the Available Toolchains}{ + + +\code{xcode_toolchains()} reports every developer directory \code{xcode-select} could +be pointed at: the Command Line Tools, plus each installed Xcode bundle +wherever it lives. Pass a \code{path} from this table to \code{\link[=xcode_cli_switch]{xcode_cli_switch()}}. + +Note that this lists \emph{developer directories}, not \code{.xctoolchain} bundles. +Those are a separate axis selected through \code{xcrun --toolchain} and the +\code{TOOLCHAINS} variable, and are mostly Swift snapshots. +} + +\examples{ +# Which developer directory is active +developer_dir() +# Check whether a compiler is actually reachable +is_xcode_toolchain_usable() +# List every toolchain xcode-select could use +xcode_toolchains() +} diff --git a/man/xcode_app_bundles.Rd b/man/xcode_app_bundles.Rd new file mode 100644 index 0000000..eb86e8a --- /dev/null +++ b/man/xcode_app_bundles.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{xcode_app_bundles} +\alias{xcode_app_bundles} +\title{Locate Every Installed Xcode Bundle} +\usage{ +xcode_app_bundles() +} +\value{ +Paths to the installed Xcode bundles, possibly empty. +} +\description{ +Locate Every Installed Xcode Bundle +} +\details{ +Xcode is routinely installed under a versioned name, which is what every +GitHub-hosted macOS runner does (\verb{/Applications/Xcode_26.6.app}), and +\verb{man xcode-select} documents non-default locations as supported. Spotlight +is queried by bundle identifier so the name and location do not matter, with +a glob fallback for machines where Spotlight indexing is disabled. Time +Machine copies are discarded. +} +\keyword{internal} diff --git a/man/xcode_app_version.Rd b/man/xcode_app_version.Rd new file mode 100644 index 0000000..af79177 --- /dev/null +++ b/man/xcode_app_version.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/xcode.R +\name{xcode_app_version} +\alias{xcode_app_version} +\title{Version of an Xcode Bundle} +\usage{ +xcode_app_version(bundle) +} +\arguments{ +\item{bundle}{Path to an \code{Xcode.app} bundle.} +} +\value{ +The bundle's short version string, or \code{NA_character_}. +} +\description{ +Version of an Xcode Bundle +} +\keyword{internal} diff --git a/tests/testthat/test-toolchain.R b/tests/testthat/test-toolchain.R index be00985..0948383 100644 --- a/tests/testthat/test-toolchain.R +++ b/tests/testthat/test-toolchain.R @@ -89,13 +89,20 @@ test_that("rtools_install_xcode_cli installs when missing and aborts on failure" mockery::stub(rtools_install_xcode_cli, "cli::cli_alert_info", function(...) NULL) mockery::stub(rtools_install_xcode_cli, "cli::cli_bullets", function(...) NULL) mockery::stub(rtools_install_xcode_cli, "cli::cli_progress_update", function(...) NULL) + # Unusable before the install, usable after, which is what a successful + # install actually looks like. + installed <- FALSE local_mocked_bindings( - is_xcode_app_installed = function() FALSE, + is_xcode_toolchain_usable = function() installed, is_xcode_cli_installed = function() FALSE, - xcode_cli_install = function(...) TRUE + xcode_cli_install = function(...) { + installed <<- TRUE + TRUE + } ) expect_true(rtools_install_xcode_cli("pw", FALSE, FALSE, NULL)) + installed <- FALSE local_mocked_bindings(xcode_cli_install = function(...) FALSE) expect_error(rtools_install_xcode_cli("pw", FALSE, FALSE, NULL), "Failed to install Xcode") }) @@ -186,3 +193,130 @@ test_that("macos_rtools_uninstall handles component failures", { expect_error(macos_rtools_uninstall(verbose = TRUE), "Uninstallation failed") }) + +test_that("rtools_install_xcode_cli skips installing when a toolchain is already usable", { + # Regression: a versioned Xcode.app (what every GitHub runner selects) used + # to be misread as "no toolchain" and triggered a 10-15 minute install of + # Command Line Tools that were already present. + mockery::stub(rtools_install_xcode_cli, "cli::cli_h3", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_text", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_ul", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_alert_info", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_bullets", function(...) NULL) + mockery::stub(rtools_install_xcode_cli, "cli::cli_progress_update", function(...) NULL) + local_mocked_bindings( + is_xcode_toolchain_usable = function() TRUE, + developer_dir = function() "/Applications/Xcode_26.6.app/Contents/Developer", + xcode_app_bundles = function() "/Applications/Xcode_26.6.app", + xcode_app_version = function(bundle) "26.6", + xcode_cli_install = function(...) stop("must not install when a toolchain is usable") + ) + + expect_true(rtools_install_xcode_cli("pw", FALSE, FALSE, NULL)) +}) + +# Helper: capture the cli message templates emitted by the install step. +capture_xcode_cli_messages <- function(...) { + seen <- base::character(0) + record <- function(...) { + seen <<- c(seen, base::unlist(base::list(...))) + NULL + } + for (fn in c("cli::cli_h3", "cli::cli_text", "cli::cli_ul", + "cli::cli_progress_update")) { + mockery::stub(rtools_install_xcode_cli, fn, function(...) NULL) + } + for (fn in c("cli::cli_alert_info", "cli::cli_alert_warning", + "cli::cli_alert_success", "cli::cli_bullets")) { + mockery::stub(rtools_install_xcode_cli, fn, record) + } + result <- rtools_install_xcode_cli("pw", FALSE, TRUE, NULL) + base::list(result = result, messages = base::paste(seen, collapse = " | ")) +} + +test_that("a CLT-only machine is not reported as having the full Xcode IDE", { + # Regression: the install gate moved to is_xcode_toolchain_usable() but the + # reporting branch still announced "Full Xcode.app IDE is installed" for + # any usable toolchain, including a machine with no Xcode at all. + local_mocked_bindings( + is_xcode_toolchain_usable = function() TRUE, + is_xcode_cli_installed = function() TRUE, + developer_dir = function() "/Library/Developer/CommandLineTools", + developer_dir_is_app = function(...) FALSE, + xcode_cli_version = function() "27.0.0.0.1788430756", + xcode_cli_install = function(...) stop("must not install") + ) + + out <- capture_xcode_cli_messages() + + expect_true(out$result) + expect_no_match(out$messages, "Full Xcode.app IDE", fixed = TRUE) + expect_match(out$messages, "Command Line Tools", fixed = TRUE) +}) + +test_that("an active full Xcode is reported as such, at its real location", { + local_mocked_bindings( + is_xcode_toolchain_usable = function() TRUE, + is_xcode_cli_installed = function() FALSE, + developer_dir = function() "/Applications/Xcode_26.6.app/Contents/Developer", + developer_dir_is_app = function(...) TRUE, + xcode_app_version = function(bundle) "26.6", + xcode_cli_install = function(...) stop("must not install") + ) + + out <- capture_xcode_cli_messages() + + expect_true(out$result) + expect_match(out$messages, "Xcode", fixed = TRUE) +}) + +test_that("CLT on disk with an unusable toolchain is reported as a failure", { + # Regression: this state returned TRUE and printed "Pre-installed, no + # action needed", so the install summary claimed success on a machine that + # could not compile. + local_mocked_bindings( + is_xcode_toolchain_usable = function() FALSE, + is_xcode_cli_installed = function() TRUE, + developer_dir = function() NA_character_, + xcode_cli_install = function(...) stop("reinstalling cannot fix a bad selection") + ) + + out <- capture_xcode_cli_messages() + + expect_false(out$result) + expect_no_match(out$messages, "no action needed", fixed = TRUE) +}) + +test_that("an install that leaves the toolchain unreachable is not reported as success", { + # Installing the Command Line Tools does not guarantee they became + # reachable: a stale DEVELOPER_DIR or selection still shadows them, so the + # component must verify rather than trust the installer's return value. + local_mocked_bindings( + is_xcode_toolchain_usable = function() FALSE, + is_xcode_cli_installed = function() FALSE, + developer_dir = function() "/nonexistent/stale", + xcode_cli_install = function(...) TRUE + ) + + out <- capture_xcode_cli_messages() + + expect_false(out$result) +}) + +test_that("an install that yields a reachable toolchain is reported as success", { + # First call gates the install, the second is the post-install check. + calls <- 0L + local_mocked_bindings( + is_xcode_toolchain_usable = function() { + calls <<- calls + 1L + calls > 1L + }, + is_xcode_cli_installed = function() FALSE, + xcode_cli_install = function(...) TRUE + ) + + out <- capture_xcode_cli_messages() + + expect_true(out$result) + expect_equal(calls, 2L) +}) diff --git a/tests/testthat/test-xcode.R b/tests/testthat/test-xcode.R index eed235e..22afb69 100644 --- a/tests/testthat/test-xcode.R +++ b/tests/testthat/test-xcode.R @@ -24,82 +24,65 @@ test_that("xcode_select_path returns correct output", { expect_equal(result$output, "/Library/Developer/CommandLineTools") }) -test_that("is_xcode_cli_installed correctly identifies installed CLI tools", { - # First, mock dependencies for success scenario +test_that("is_xcode_cli_installed reports CLT package presence, not the selection", { + # New semantics: the CLT package either is or is not installed. Which + # developer directory happens to be selected is a different question, + # answered by is_xcode_toolchain_usable(). local_mocked_bindings( assert_mac = function() TRUE, - # Mock successful path detection - xcode_select_path = function() { - base::structure( - base::list( - output = "/Library/Developer/CommandLineTools", - error = "", - status = 0L - ), - class = c("xcodeselect", "cli") - ) - }, - # Mock installation directory exists install_directory_xcode_cli = function() "/Library/Developer/CommandLineTools" ) - mockery::stub(is_xcode_cli_installed, "base::dir.exists", function(path) TRUE) + # The stub is argument-aware so the test pins WHICH path is probed, not + # merely that file.exists() was consulted. + clang <- "/Library/Developer/CommandLineTools/usr/bin/clang" + mockery::stub(is_xcode_cli_installed, "base::file.exists", + function(path) base::identical(path, clang)) expect_true(is_xcode_cli_installed()) - # Now mock failure scenarios + mockery::stub(is_xcode_cli_installed, "base::file.exists", + function(path) base::identical(path, "/some/other/clang")) + expect_false(is_xcode_cli_installed()) +}) + +test_that("is_xcode_cli_installed ignores a non-standard active developer directory", { + # The reported bug: a versioned Xcode.app is selected, CLT is installed, + # and the old exact-equality predicate returned FALSE. local_mocked_bindings( assert_mac = function() TRUE, + install_directory_xcode_cli = function() "/Library/Developer/CommandLineTools", xcode_select_path = function() { base::structure( base::list( - output = "/Library/Developer/CommandLineTools", + output = "/Applications/Xcode_16.4.app/Contents/Developer", error = "", - status = 1L # Non-zero status + status = 0L ), class = c("xcodeselect", "cli") ) } ) + mockery::stub( + is_xcode_cli_installed, "base::file.exists", + function(path) base::identical( + path, "/Library/Developer/CommandLineTools/usr/bin/clang" + ) + ) - expect_false(is_xcode_cli_installed()) + expect_true(is_xcode_cli_installed()) }) -test_that("is_xcode_app_installed correctly identifies installed Xcode app", { - # Mock dependencies for success scenario +test_that("is_xcode_app_installed accepts any Xcode bundle location", { local_mocked_bindings( assert_mac = function() TRUE, - # Mock successful path detection - xcode_select_path = function() { - base::structure( - base::list( - output = "/Applications/Xcode.app/Contents/Developer", - error = "", - status = 0L - ), - class = c("xcodeselect", "cli") - ) - }, - # Mock installation directory exists - install_directory_xcode_app = function() "/Applications/Xcode.app/Contents/Developer" + xcode_app_bundles = function() "/Applications/Xcode_16.4.app" ) - mockery::stub(is_xcode_app_installed, "base::dir.exists", function(path) TRUE) - expect_true(is_xcode_app_installed()) - # Now mock failure scenarios local_mocked_bindings( - xcode_select_path = function() { - base::structure( - base::list( - output = "/Library/Developer/CommandLineTools", # Wrong path - error = "", - status = 0L - ), - class = c("xcodeselect", "cli") - ) - } + assert_mac = function() TRUE, + xcode_app_bundles = function() base::character(0) ) - expect_false(is_xcode_app_installed()) }) @@ -164,3 +147,367 @@ test_that("xcode_cli_install skips when Xcode app is installed", { result <- xcode_cli_install(verbose = TRUE) expect_true(result) }) + +test_that("normalize_developer_dir expands a bundle path and strips trailing slashes", { + # Deliberately under a root that cannot exist. normalizePath() resolves + # symlinks for real paths, and on CI runners /Applications/Xcode.app is a + # symlink to the versioned default, so using real paths here would assert + # the filesystem rather than this function's own logic. + root <- "/nonexistent-macrtools-test" + + expect_equal( + normalize_developer_dir(base::file.path(root, "Xcode_16.4.app")), + base::file.path(root, "Xcode_16.4.app/Contents/Developer") + ) + expect_equal( + normalize_developer_dir(base::file.path(root, "CommandLineTools/")), + base::file.path(root, "CommandLineTools") + ) + # An already-expanded developer directory is left alone. + expect_equal( + normalize_developer_dir(base::file.path(root, "Xcode.app/Contents/Developer")), + base::file.path(root, "Xcode.app/Contents/Developer") + ) +}) + +test_that("normalize_developer_dir is vectorized", { + # xcode_toolchains() normalizes a whole column at once, so a scalar-only + # implementation errors with "the condition has length > 1". + root <- "/nonexistent-macrtools-test" + + expect_equal( + normalize_developer_dir(base::file.path(root, c( + "Xcode_16.4.app", + "CommandLineTools", + "Xcode.app/Contents/Developer" + ))), + base::file.path(root, c( + "Xcode_16.4.app/Contents/Developer", + "CommandLineTools", + "Xcode.app/Contents/Developer" + )) + ) + expect_equal(normalize_developer_dir(base::character(0)), base::character(0)) +}) + +test_that("normalize_developer_dir resolves a symlinked bundle", { + # This is what makes exact path equality unusable: a runner's + # /Applications/Xcode.app is a symlink to the versioned bundle it defaults + # to, so the two spellings must compare equal after normalization. + scratch <- base::file.path(base::tempdir(), "macrtools-symlink-test") + base::dir.create(base::file.path(scratch, "Real.app", "Contents", "Developer"), + recursive = TRUE, showWarnings = FALSE) + on.exit(base::unlink(scratch, recursive = TRUE), add = TRUE) + + link <- base::file.path(scratch, "Link.app") + skip_if_not(base::file.symlink(base::file.path(scratch, "Real.app"), link), + "could not create a symlink") + + expect_equal( + normalize_developer_dir(link), + normalize_developer_dir(base::file.path(scratch, "Real.app")) + ) +}) + +test_that("developer_dir prefers DEVELOPER_DIR over the stored selection", { + # DEVELOPER_DIR overrides xcode-select for xcrun and the /usr/bin shims, + # so it must win here too. + local_mocked_bindings( + xcode_select_path = function() { + base::structure( + base::list(output = "/Library/Developer/CommandLineTools", + error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + } + ) + mockery::stub(developer_dir, "base::Sys.getenv", + function(...) "/Applications/Xcode_16.4.app/Contents/Developer") + mockery::stub(developer_dir, "base::dir.exists", function(path) TRUE) + + expect_equal(developer_dir(), "/Applications/Xcode_16.4.app/Contents/Developer") +}) + +test_that("developer_dir falls back to the stored selection and is NA when unusable", { + local_mocked_bindings( + xcode_select_path = function() { + base::structure( + base::list(output = "/Applications/Xcode_26.6.app/Contents/Developer", + error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + } + ) + mockery::stub(developer_dir, "base::Sys.getenv", function(...) "") + mockery::stub(developer_dir, "base::dir.exists", function(path) TRUE) + expect_equal(developer_dir(), "/Applications/Xcode_26.6.app/Contents/Developer") + + # xcode-select echoes a path and exits 0 even when it does not exist, so a + # stale selection must resolve to NA rather than a bogus path. + mockery::stub(developer_dir, "base::dir.exists", function(path) FALSE) + expect_true(base::is.na(developer_dir())) +}) + +test_that("developer_dir is NA when nothing is selected", { + local_mocked_bindings( + xcode_select_path = function() { + base::structure( + base::list(output = "", error = "unable to get active developer directory", + status = 2L), + class = c("xcodeselect", "cli") + ) + } + ) + mockery::stub(developer_dir, "base::Sys.getenv", function(...) "") + expect_true(base::is.na(developer_dir())) +}) + +test_that("xcode_cli_version reads the pkgutil receipt", { + local_mocked_bindings( + is_xcode_cli_installed = function() TRUE, + exec_text = function(...) c( + "package-id: com.apple.pkg.CLTools_Executables", + "version: 27.0.0.0.1788430756", + "volume: /" + ) + ) + expect_equal(xcode_cli_version(), "27.0.0.0.1788430756") + + local_mocked_bindings(is_xcode_cli_installed = function() FALSE) + expect_true(base::is.na(xcode_cli_version())) +}) + +test_that("xcode_app_bundles drops Time Machine hits and bundles without a developer dir", { + local_mocked_bindings( + exec_text = function(...) c( + "/Applications/Xcode.app", + "/Volumes/TM/Backups.backupdb/mac/2026-01-01/Applications/Xcode.app", + "/Applications/Broken.app" + ) + ) + mockery::stub(xcode_app_bundles, "base::Sys.glob", function(...) base::character(0)) + mockery::stub(xcode_app_bundles, "base::dir.exists", + function(path) !base::grepl("Broken", path)) + mockery::stub(xcode_app_bundles, "base::normalizePath", function(path, ...) path) + + expect_equal(xcode_app_bundles(), "/Applications/Xcode.app") +}) + +test_that("is_xcode_toolchain_usable requires an executable compiler, not just exit 0", { + # xcrun can exit 0 while printing an error for a degraded bundle, so the + # returned path must be confirmed to exist and be executable. + local_mocked_bindings( + exec_text = function(...) "/Applications/Xcode.app/Contents/Developer/usr/bin/clang" + ) + mockery::stub(is_xcode_toolchain_usable, "base::file.exists", function(path) TRUE) + mockery::stub(is_xcode_toolchain_usable, "base::file.access", function(...) 0L) + expect_true(is_xcode_toolchain_usable()) + + mockery::stub(is_xcode_toolchain_usable, "base::file.exists", function(path) FALSE) + expect_false(is_xcode_toolchain_usable()) +}) + +test_that("is_xcode_toolchain_usable is FALSE when xcrun fails", { + local_mocked_bindings(exec_text = function(..., fallback = NA_character_) NA_character_) + expect_false(is_xcode_toolchain_usable()) +}) + +test_that("xcode_toolchains lists CLT and every Xcode bundle, flagging the active one", { + local_mocked_bindings( + assert_mac = function() TRUE, + is_xcode_cli_installed = function() TRUE, + xcode_cli_version = function() "27.0.0.0.1788430756", + install_directory_xcode_cli = function() "/Library/Developer/CommandLineTools", + xcode_app_bundles = function() c("/Applications/Xcode.app", "/Applications/Xcode_16.4.app"), + xcode_app_version = function(bundle) if (base::grepl("16.4", bundle)) "16.4" else "27.0", + developer_dir = function() "/Applications/Xcode_16.4.app/Contents/Developer" + ) + + out <- xcode_toolchains() + + expect_s3_class(out, "data.frame") + expect_equal(base::nrow(out), 3L) + expect_equal(base::names(out), c("path", "type", "version", "active")) + expect_equal(base::sum(out$active), 1L) + expect_equal(out$path[out$active], "/Applications/Xcode_16.4.app/Contents/Developer") + expect_true("cli" %in% out$type) + expect_equal(out$version[out$type == "cli"], "27.0.0.0.1788430756") +}) + +test_that("xcode_toolchains returns a zero-row frame when nothing is installed", { + local_mocked_bindings( + assert_mac = function() TRUE, + is_xcode_cli_installed = function() FALSE, + xcode_app_bundles = function() base::character(0), + developer_dir = function() NA_character_ + ) + + out <- xcode_toolchains() + expect_s3_class(out, "data.frame") + expect_equal(base::nrow(out), 0L) + expect_equal(base::names(out), c("path", "type", "version", "active")) +}) + +test_that("xcode_cli_switch rejects a target that is not a developer directory", { + local_mocked_bindings( + assert_mac = function() TRUE, + shell_execute = function(...) stop("shell_execute must not run for an invalid target") + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) FALSE) + + expect_error( + xcode_cli_switch("/Applications/NotXcode.app", verbose = FALSE), + regexp = "not a developer directory" + ) +}) + +test_that("xcode_cli_switch is a no-op when the target is already the stored selection", { + local_mocked_bindings( + assert_mac = function() TRUE, + xcode_select_path = function() { + base::structure( + base::list(output = "/Library/Developer/CommandLineTools", error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + }, + shell_execute = function(...) stop("shell_execute must not run for a no-op switch") + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) TRUE) + + expect_true(xcode_cli_switch("/Library/Developer/CommandLineTools", verbose = FALSE)) +}) + +test_that("xcode_cli_switch still switches when only DEVELOPER_DIR matches the target", { + # DEVELOPER_DIR overrides xcrun and the shims, but --switch writes the + # STORED selection. Treating the override as "already active" would skip a + # switch that never happened, and unsetting the variable would revert. + commands <- base::character(0) + local_mocked_bindings( + assert_mac = function() TRUE, + developer_dir = function() "/Library/Developer/CommandLineTools", + xcode_select_path = function() { + base::structure( + base::list(output = "/Applications/Xcode.app/Contents/Developer", error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + }, + shell_execute = function(cmd, ...) { + commands <<- c(commands, cmd) + 0L + } + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) TRUE) + + expect_true(xcode_cli_switch("/Library/Developer/CommandLineTools", verbose = FALSE)) + expect_length(commands, 1L) +}) + +test_that("xcode_cli_switch does not echo its path argument, which may be a password", { + # `path` took `password`'s position, so a legacy positional call arrives + # here carrying a sudo password. It must never reach the error message. + local_mocked_bindings( + assert_mac = function() TRUE, + shell_execute = function(...) stop("must not run for an invalid target") + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) FALSE) + + err <- base::tryCatch( + xcode_cli_switch("sup3rs3cret", verbose = FALSE), + error = function(e) base::conditionMessage(e) + ) + expect_no_match(err, "sup3rs3cret", fixed = TRUE) + expect_match(err, "not a developer directory", fixed = TRUE) +}) + +test_that("xcode_cli_switch rejects an existing directory that is not a developer dir", { + local_mocked_bindings( + assert_mac = function() TRUE, + shell_execute = function(...) stop("must not run for an invalid target") + ) + # The directory exists, but has no usr/bin beneath it. + mockery::stub(xcode_cli_switch, "base::dir.exists", + function(path) !base::grepl("usr/bin", path, fixed = TRUE)) + + expect_error(xcode_cli_switch("/tmp", verbose = FALSE), "not a developer directory") +}) + +test_that("xcode_cli_switch accepts an explicit non-CLT target", { + commands <- base::character(0) + local_mocked_bindings( + assert_mac = function() TRUE, + xcode_select_path = function() { + base::structure( + base::list(output = "/Library/Developer/CommandLineTools", error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + }, + shell_execute = function(cmd, ...) { + commands <<- c(commands, cmd) + 0L + } + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) TRUE) + + expect_true(xcode_cli_switch("/Applications/Xcode_16.4.app", verbose = FALSE)) + expect_length(commands, 1L) + # The target is shell-quoted so a path containing spaces cannot split. + expect_match( + commands[1], + base::paste("--switch", base::shQuote("/Applications/Xcode_16.4.app/Contents/Developer")), + fixed = TRUE + ) +}) + +test_that("xcode_cli_switch quotes a target containing spaces", { + commands <- base::character(0) + local_mocked_bindings( + assert_mac = function() TRUE, + xcode_select_path = function() { + base::structure( + base::list(output = "/Library/Developer/CommandLineTools", error = "", status = 0L), + class = c("xcodeselect", "cli") + ) + }, + shell_execute = function(cmd, ...) { + commands <<- c(commands, cmd) + 0L + } + ) + mockery::stub(xcode_cli_switch, "base::dir.exists", function(path) TRUE) + mockery::stub(xcode_cli_switch, "base::normalizePath", function(path, ...) path) + + xcode_cli_switch("/Volumes/My Disk/Xcode.app", verbose = FALSE) + + expect_match(commands[1], "'/Volumes/My Disk/Xcode.app/Contents/Developer'", fixed = TRUE) +}) + +test_that("xcode_cli_uninstall never derives its removal target from the selection", { + # Safety invariant: the rm target is a literal. A non-standard or versioned + # Xcode selection must never appear in a destructive command. + commands <- base::character(0) + local_mocked_bindings( + assert_mac = function() TRUE, + is_xcode_cli_installed = function() TRUE, + developer_dir = function() "/Applications/Xcode_16.4.app/Contents/Developer", + shell_execute = function(cmd, ...) { + commands <<- c(commands, cmd) + 0L + } + ) + + xcode_cli_uninstall(verbose = FALSE) + + expect_length(commands, 1L) + expect_equal(commands[1], "rm -rf /Library/Developer/CommandLineTools") + expect_false(base::any(base::grepl("Xcode_16.4", commands))) +}) + +test_that("xcode_cli_uninstall reports not-installed rather than success", { + local_mocked_bindings( + assert_mac = function() TRUE, + is_xcode_cli_installed = function() FALSE, + shell_execute = function(...) stop("must not run when CLT is absent") + ) + # FALSE means "nothing was removed", so callers cannot report success. + expect_false(xcode_cli_uninstall(verbose = FALSE)) +})