From 5c0e59335c9e5de714d387f1e87f0a3d8eeafcc8 Mon Sep 17 00:00:00 2001 From: "Jennifer (Jenny) Bryan" Date: Mon, 5 Aug 2024 13:21:45 -0700 Subject: [PATCH] Discover R on PATH on windows (#4190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #3702 Also related to https://github.com/posit-dev/positron/discussions/3677 and #3868 This PR deals with this situation: * R is installed in a non-default location. * We can't learn about this R installation from the registry (either because registry keys were not written or permissions are keeping us from reading the registry). * The user has arranged for R to be discovered on the PATH. ### QA Notes To experience the before vs. after, you need very specific setup. We're trying to imitate a user with R in a non-default location and no permission to access the registry. To make R discoverable, this user is relying solely on the fact that they've put R on their PATH. If you don't have a completely locked down corporate Windows laptop, follow the instructions below to fake it 😬 Before: in a released version of Positron (or a release built before this PR gets merged), the R version set up as described below WILL NOT be discovered by Positron. After: with this PR, the R version set up as described below WILL be discovered by Positron. You can also see more about discovery in the Positron R Extension output channel. (It is expected that you will see that some R versions are discovered more than once.) You should see new logging messages like this: `2024-08-01 16:19:27.712 [info] Possibly found R on PATH: C:\notADefaultFolder\R-4.2.3\bin\x64\R.EXE.` --- 1. **OS must be Windows**. At least one R version needs to be: 2. **Installed in an unusual place** where Positron will not automatically discover it. TL;DR is to install somewhere other than `C:\Program Files` or `C:\R`. For a more precise definition, here is the [actual code](https://github.com/posit-dev/positron/blob/4a05a525c7da4bb2778490eb6a244e0f4b54ccef/extensions/positron-r/src/provider.ts#L228-L236). For example, I chose to put this non-default R installation in `C:\notADefaultFolder` . This will be easiest to do if you use the CRAN installer directly (so, not using rig). You can get old versions of R here: [Previous releases of R for Windows (r-project.org)](https://cran.r-project.org/bin/windows/base/old/) select-destination 4. **Not recorded as the current version of R in the registry**. Make sure it is NOT stored as the `InstallPath` subkey in these locations: - `HKEY_LOCAL_MACHINE\SOFTWARE\R-core\R64` - `HKEY_LOCAL_MACHINE\SOFTWARE\R-core\R` - `HKEY_CURRENT_USER\SOFTWARE\R-core\R64` - `HKEY_CURRENT_USER\SOFTWARE\R-core\R` - `HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\R-core\R` - `HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\R-core\R64` - `HKEY_CURRENT_USER\SOFTWARE\WOW6432Node\R-core\R` - `HKEY_CURRENT_USER\SOFTWARE\WOW6432Node\R-core\R64` Ways to accomplish this: - Don't let the installer write to the registry. save-version-registry - Manually delete keys via the Registry Editor. - Install some *other* version of R after this one and let *that* version be recorded in the registry as the current version of R. - Use utilities that ship with R: > After installation you can add the Registry entries by running `RSetReg.exe` in a sub-folder of the `bin` folder, and remove them by running this with argument `/U`. Note that this requires administrative privileges unless run with argument `/Personal` and neither sets up nor removes the file associations. 5. **Have its `bin\x64` or `bin` directory on the PATH** (system or user). Neither rig nor the CRAN installer do this, so this has to be done explicitly. For example, the path I added to my user PATH is `C:\notADefaultFolder\R-4.2.3\bin\x64`. There seem to be many ways to do this, but here's what I did: Go to About your PC \> Advanced system settings (in list on the right) \> Environment Variables. Add an entry to `Path` for the user or the system. edit-environment-variable You'll need to restart any shells or maybe log out / log in for this to take effect. Test that this has worked by launching R in a terminal and confirm it's launching the intended version. I verified this in cmd.exe, PowerShell, and Git Bash FWIW. --------- Signed-off-by: Jennifer (Jenny) Bryan Co-authored-by: Jonathan --- extensions/positron-r/src/provider.ts | 94 ++++++++++++--------- extensions/positron-r/src/r-installation.ts | 30 +++---- 2 files changed, 69 insertions(+), 55 deletions(-) diff --git a/extensions/positron-r/src/provider.ts b/extensions/positron-r/src/provider.ts index ab56967f72b..f4906e1770d 100644 --- a/extensions/positron-r/src/provider.ts +++ b/extensions/positron-r/src/provider.ts @@ -58,6 +58,11 @@ export async function* rRuntimeDiscoverer(): AsyncGenerator { if (os.platform() === 'win32') { - const registryBinary = findCurrentRBinaryFromRegistry(); + const registryBinary = await findCurrentRBinaryFromRegistry(); if (registryBinary) { return registryBinary; } } + return findRBinaryFromPATH(); +} +async function findRBinaryFromPATH(): Promise { const whichR = await which('R', { nothrow: true }) as string; if (whichR) { + LOGGER.info(`Possibly found R on PATH: ${whichR}.`); if (os.platform() === 'win32') { - // rig puts {R Headquarters}/bin on the PATH, so that is probably how we got here. - // (The CRAN installer does NOT put R on the PATH.) - // In the rig scenario, `whichR` is anticipated to be a batch file that launches the - // current version of R ('default' in rig-speak): - // Example filepath: C:\Program Files\R\bin\R.bat - // Typical contents of this file: - // ::4.3.2 - // @"C:\Program Files\R\R-4.3.2\bin\R" %* - // How it looks when r-devel is current: - // ::devel - // @"C:\Program Files\R\R-devel\bin\R" %* - // Note that this is not our preferred x64 binary, so we try to convert it. - if (path.extname(whichR).toLowerCase() === '.bat') { - const batLines = readLines(whichR); - const re = new RegExp(`^@"(.+R-(devel|[0-9]+[.][0-9]+[.][0-9]+).+)" %[*]$`); - const match = batLines.find((x: string) => re.test(x))?.match(re); - if (match) { - const whichRMatched = match[1]; - const whichRHome = getRHomePath(whichRMatched); - if (!whichRHome) { - LOGGER.info(`Failed to get R home path from ${whichRMatched}`); - return undefined; - } - // we prefer the x64 binary - const whichRResolved = firstExisting(whichRHome, binFragments()); - if (whichRResolved) { - LOGGER.info(`Resolved R binary at ${whichRResolved}`); - return whichRResolved; - } else { - LOGGER.info(`Can\'t find R binary within ${whichRHome}`); - return undefined; - } - } - } - // TODO: handle the case where whichR isn't picking up the rig case; do people do this, - // meaning put R on the PATH themselves, on Windows? + return await findRBinaryFromPATHWindows(whichR); } else { - const whichRCanonical = fs.realpathSync(whichR); - LOGGER.info(`Resolved R binary at ${whichRCanonical}`); - return whichRCanonical; + return await findRBinaryFromPATHNotWindows(whichR); } + } else { + return undefined; } } +async function findRBinaryFromPATHWindows(whichR: string): Promise { + // The CRAN Windows installer does NOT put R on the PATH. + // If we are here, it is because the user has arranged it so. + const ext = path.extname(whichR).toLowerCase(); + if (ext !== '.exe') { + // rig can put put something on the PATH that results in whichR being 'a/path/to/R.bat' + // but we aren't going to handle that. + LOGGER.info(`Unsupported extension: ${ext}.`); + return undefined; + } + + // Overall idea: a discovered binpath --> homepath --> our preferred binpath + // This might just be a no-op. + // But if the input binpath is this: + // "C:\Program Files\R\R-4.3.2\bin\R.exe" + // we want to convert it to this, if it exists: + // "C:\Program Files\R\R-4.3.2\bin\x64\R.exe" + // It typically does exist for x86_64 R installations. + // It will not exist for arm64 R installations. + const whichRHome = getRHomePath(whichR); + if (!whichRHome) { + LOGGER.info(`Failed to get R home path from ${whichR}.`); + return undefined; + } + const binpathNormalized = firstExisting(whichRHome, binFragments()); + if (binpathNormalized) { + LOGGER.info(`Resolved R binary at ${binpathNormalized}.`); + return binpathNormalized; + } else { + LOGGER.info(`Can't find R binary within ${whichRHome}.`); + return undefined; + } +} + +async function findRBinaryFromPATHNotWindows(whichR: string): Promise { + const whichRCanonical = fs.realpathSync(whichR); + LOGGER.info(`Resolved R binary at ${whichRCanonical}`); + return whichRCanonical; +} + async function findCurrentRBinaryFromRegistry(): Promise { let userPath = await getRegistryInstallPath(winreg.HKCU); if (!userPath) { diff --git a/extensions/positron-r/src/r-installation.ts b/extensions/positron-r/src/r-installation.ts index ec40e6d73a0..e07d0c991f8 100644 --- a/extensions/positron-r/src/r-installation.ts +++ b/extensions/positron-r/src/r-installation.ts @@ -134,28 +134,28 @@ export class RInstallation { } } -export function getRHomePath(binPath: string): string | undefined { +export function getRHomePath(binpath: string): string | undefined { switch (process.platform) { case 'darwin': case 'linux': - return getRHomePathNotWindows(binPath); + return getRHomePathNotWindows(binpath); case 'win32': - return getRHomePathWindows(binPath); + return getRHomePathWindows(binpath); default: throw new Error('Unsupported platform'); } } -function getRHomePathNotWindows(binPath: string): string | undefined { - const binLines = readLines(binPath); +function getRHomePathNotWindows(binpath: string): string | undefined { + const binLines = readLines(binpath); const re = new RegExp('Shell wrapper for R executable'); if (!binLines.some(x => re.test(x))) { - LOGGER.info(`Binary is not a shell script wrapping the executable: ${binPath}`); + LOGGER.info(`Binary is not a shell script wrapping the executable: ${binpath}`); return undefined; } const targetLine = binLines.find(line => line.match('R_HOME_DIR')); if (!targetLine) { - LOGGER.info(`Can\'t determine R_HOME_DIR from the binary: ${binPath}`); + LOGGER.info(`Can\'t determine R_HOME_DIR from the binary: ${binpath}`); return undefined; } // macOS: R_HOME_DIR=/Library/Frameworks/R.framework/Versions/4.3-arm64/Resources @@ -165,23 +165,23 @@ function getRHomePathNotWindows(binPath: string): string | undefined { const R_HOME_DIR = removeSurroundingQuotes(extractValue(targetLine, 'R_HOME_DIR')); const homepath = R_HOME_DIR; if (homepath === '') { - LOGGER.info(`Can\'t determine R_HOME_DIR from the binary: ${binPath}`); + LOGGER.info(`Can\'t determine R_HOME_DIR from the binary: ${binpath}`); return undefined; } return homepath; } -function getRHomePathWindows(binPath: string): string | undefined { +function getRHomePathWindows(binpath: string): string | undefined { // find right-most 'bin' in the path and take everything to the left of it - // Examples of binPaths: - // "C:\Program Files\R\R-4.3.2\bin\R.exe" <-- the path produced by our binFragment() helper - // "C:\Program Files\R\R-4.3.2\bin\x64\R.exe" <-- but this also exists - const binIndex = binPath.lastIndexOf(path.sep + 'bin' + path.sep); + // Examples of binpaths: + // "C:\Program Files\R\R-4.3.2\bin\x64\R.exe" <-- we prefer this, if both are present + // "C:\Program Files\R\R-4.3.2\bin\R.exe" <-- usually a shim for the path above + const binIndex = binpath.lastIndexOf(path.sep + 'bin' + path.sep); if (binIndex === -1) { - LOGGER.info(`Can\'t determine R_HOME_DIR from the path to the R binary: ${binPath}`); + LOGGER.info(`Can\'t determine R_HOME_DIR from the path to the R binary: ${binpath}`); return undefined; } else { - const pathUpToBin = binPath.substring(0, binIndex); + const pathUpToBin = binpath.substring(0, binIndex); return pathUpToBin; }