From 0a99efc71eb6f1e546d4cc7c5e4bb740dc394dfe Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 19 Sep 2026 09:12:40 +0000 Subject: [PATCH 1/4] feat(config): add canonical R executable settings --- package.json | 53 ++-- src/helpViewer/index.ts | 3 +- src/rPathResolver.ts | 189 +++++++++++++ src/test/suite/rPathResolver.test.ts | 394 +++++++++++++++++++++++++++ src/util.ts | 185 +++++-------- 5 files changed, 682 insertions(+), 142 deletions(-) create mode 100644 src/rPathResolver.ts create mode 100644 src/test/suite/rPathResolver.test.ts diff --git a/package.json b/package.json index f9b1a747..7eb24b9e 100644 --- a/package.json +++ b/package.json @@ -1281,35 +1281,59 @@ "type": "object", "title": "R", "properties": { + "r.executablePath": { + "type": "string", + "default": "", + "scope": "machine-overridable", + "markdownDescription": "Absolute or substituted path to the vanilla R executable used for background processes, or an executable name available on `PATH`. Do not use alternative consoles such as arf or radian. The supported substitutions are `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}`." + }, + "r.consolePath": { + "type": "string", + "default": "", + "scope": "machine-overridable", + "markdownDescription": "Absolute or substituted path to the executable used to launch the interactive R console, or an executable name available on `PATH`. Alternative consoles such as arf (or radian) are supported. The supported substitutions are `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}`." + }, "r.rpath.windows": { "type": "string", "default": "", - "markdownDescription": "Path to an R executable to launch R background processes (Windows). Must be \"vanilla\" R, not radian etc.! Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported." + "markdownDescription": "Path to an R executable to launch R background processes (Windows). Must be \"vanilla\" R, not radian etc.! Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "markdownDeprecationMessage": "Deprecated. Use `#r.executablePath#` instead.", + "deprecationMessage": "Deprecated. Use r.executablePath instead." }, "r.rpath.mac": { "type": "string", "default": "", - "markdownDescription": "Path to an R executable to launch R background processes (macOS). Must be \"vanilla\" R, not radian etc.! Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported." + "markdownDescription": "Path to an R executable to launch R background processes (macOS). Must be \"vanilla\" R, not radian etc.! Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "markdownDeprecationMessage": "Deprecated. Use `#r.executablePath#` instead.", + "deprecationMessage": "Deprecated. Use r.executablePath instead." }, "r.rpath.linux": { "type": "string", "default": "", - "markdownDescription": "Path to an R executable to launch R background processes (Linux). Must be \"vanilla\" R, not radian etc.! Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported." + "markdownDescription": "Path to an R executable to launch R background processes (Linux). Must be \"vanilla\" R, not radian etc.! Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "markdownDeprecationMessage": "Deprecated. Use `#r.executablePath#` instead.", + "deprecationMessage": "Deprecated. Use r.executablePath instead." }, "r.rterm.windows": { "type": "string", "default": "", - "markdownDescription": "R path for interactive terminals (Windows). Can also be radian etc. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported." + "markdownDescription": "R path for interactive terminals (Windows). Can also be arf or radian. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "markdownDeprecationMessage": "Deprecated. Use `#r.consolePath#` instead.", + "deprecationMessage": "Deprecated. Use r.consolePath instead." }, "r.rterm.mac": { "type": "string", "default": "", - "markdownDescription": "R path for interactive terminals (macOS). Can also be radian etc. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported." + "markdownDescription": "R path for interactive terminals (macOS). Can also be arf or radian. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "markdownDeprecationMessage": "Deprecated. Use `#r.consolePath#` instead.", + "deprecationMessage": "Deprecated. Use r.consolePath instead." }, "r.rterm.linux": { "type": "string", "default": "", - "markdownDescription": "R path for interactive terminals (Linux). Can also be radian etc. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported." + "markdownDescription": "R path for interactive terminals (Linux). Can also be arf or radian. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "markdownDeprecationMessage": "Deprecated. Use `#r.consolePath#` instead.", + "deprecationMessage": "Deprecated. Use r.consolePath instead." }, "r.rterm.option": { "type": "array", @@ -1322,16 +1346,6 @@ "type": "string" } }, - "r.rterm.preferredConsoles": { - "type": "array", - "default": [ - "R" - ], - "markdownDescription": "Ordered list of R console executable names to search for on `PATH` when no explicit terminal path is set (e.g. `r.rterm.linux`). The first entry found on `PATH` will be used. Examples: `arf`, `radian`, `R`.", - "items": { - "type": "string" - } - }, "r.libPaths": { "type": "array", "items": { @@ -1497,13 +1511,6 @@ ], "markdownDescription": "Which local directories to try for local help pages previewer. Set to `[]` to disable." }, - "r.helpPanel.rpath": { - "type": "string", - "default": "", - "markdownDescription": "DEPRECATED! Path to an R executable. Must be \"vanilla\" R, not radian etc.! Will be read from registry or path if not set.", - "markdownDeprecationMessage": "Will be deprecated. Use `#r.rpath.windows#`, `#r.rpath.mac#`, or `#r.rpath.linux#` instead.", - "deprecationMessage": "Will be deprecated. Use r.rpath.windows, r.rpath.mac, or r.rpath.linux instead." - }, "r.helpPanel.enableHoverLinks": { "type": "boolean", "default": true, diff --git a/src/helpViewer/index.ts b/src/helpViewer/index.ts index 655118b2..16b9c0e6 100644 --- a/src/helpViewer/index.ts +++ b/src/helpViewer/index.ts @@ -12,7 +12,6 @@ import { getRpath, doWithProgress, DummyMemento, - getRPathConfigEntry, escapeHtml, makeWebviewCommandUriString, uniqueEntries, @@ -532,7 +531,7 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer string | undefined; + substituteVariables: (value: string) => string; + findExecutable: (name: string) => string | undefined; + pathExists: (value: string) => boolean; + getSystemR: () => Promise; +} + +export interface SystemRResolverDependencies { + platform: NodeJS.Platform; + findExecutable: (name: string) => string | undefined; + getWindowsInstallPath: () => Promise; +} + +interface ConfiguredExecutableResolution extends ExecutableResolution { + setting: string; +} + +export function substitutePathVariables(value: string, variables: PathVariables): string { + const substitutions: ReadonlyArray<[string, string | undefined]> = [ + ['${userHome}', variables.userHome], + ['${workspaceFolder}', variables.workspaceFolder], + ['${fileWorkspaceFolder}', variables.fileWorkspaceFolder], + ['${fileDirname}', variables.fileDirname], + ]; + + let result = value; + for (const [key, replacement] of substitutions) { + if (replacement) { + result = result.replaceAll(key, replacement); + } + } + return result; +} + +export function selectWorkspaceFolder( + workspaceFolders: readonly T[] | undefined, + activeFileWorkspaceFolder: T | undefined +): T | undefined { + if (!workspaceFolders?.length) { + return undefined; + } + if (workspaceFolders.length === 1) { + return workspaceFolders[0]; + } + return activeFileWorkspaceFolder ?? workspaceFolders[0]; +} + +export function findExecutableOnPath( + executableName: string, + platform: NodeJS.Platform, + pathValue: string | undefined, + exists: (value: string) => boolean +): string | undefined { + const pathImplementation = platform === 'win32' ? path.win32 : path.posix; + const delimiter = platform === 'win32' ? ';' : ':'; + const extension = platform === 'win32' && !path.win32.extname(executableName) ? '.exe' : ''; + + for (const directory of pathValue?.split(delimiter) ?? []) { + const candidate = pathImplementation.join(directory, executableName + extension); + if (exists(candidate)) { + return candidate; + } + } + return undefined; +} + +export async function resolveSystemR(dependencies: SystemRResolverDependencies): Promise { + const fromPath = dependencies.findExecutable('R'); + if (fromPath) { + return fromPath; + } + + if (dependencies.platform === 'win32') { + const installPath = await dependencies.getWindowsInstallPath(); + if (installPath) { + return path.win32.join(installPath, 'bin', 'R.exe'); + } + } + return undefined; +} + +export async function resolveBackgroundR( + dependencies: RPathResolverDependencies, + legacySetting: string, + overwriteSetting?: string +): Promise { + const settings = overwriteSetting + ? [overwriteSetting, rExecutablePathSetting, legacySetting] + : [rExecutablePathSetting, legacySetting]; + return resolveWithFallback(settings, dependencies); +} + +export async function resolveConsoleR( + dependencies: RPathResolverDependencies, + legacySetting: string +): Promise { + return resolveWithFallback( + [rConsolePathSetting, legacySetting, rExecutablePathSetting], + dependencies + ); +} + +export function formatRPath( + resolution: ExecutableResolution, + quote: boolean, + platform: NodeJS.Platform +): string | undefined { + if (!resolution.path || !quote) { + return resolution.path; + } + + if (resolution.quote) { + if (platform === 'win32' && resolution.quote === '\'' && resolution.path.includes(' ')) { + return `"${resolution.path}"`; + } + return `${resolution.quote}${resolution.path}${resolution.quote}`; + } + + return resolution.path.includes(' ') ? `"${resolution.path}"` : resolution.path; +} + +async function resolveWithFallback( + settings: readonly string[], + dependencies: RPathResolverDependencies +): Promise { + for (const setting of settings) { + const configured = resolveConfiguredExecutable(setting, dependencies); + if (configured) { + return configured; + } + } + + return { path: await dependencies.getSystemR() }; +} + +function resolveConfiguredExecutable( + setting: string, + dependencies: RPathResolverDependencies +): ConfiguredExecutableResolution | undefined { + const rawValue = dependencies.getSetting(setting); + if (!rawValue?.trim()) { + return undefined; + } + + const substituted = dependencies.substituteVariables(rawValue).trim(); + const { value, quote } = removeWrappingQuotes(substituted); + if (!value) { + return undefined; + } + + const resolvedPath = isExecutableName(value) + ? dependencies.findExecutable(value) + : dependencies.pathExists(value) ? value : undefined; + + return { path: resolvedPath, setting, quote }; +} + +function removeWrappingQuotes(value: string): { value: string; quote?: '"' | '\'' } { + if (value.length >= 2) { + const first = value[0]; + if ((first === '"' || first === '\'') && value.at(-1) === first) { + return { value: value.slice(1, -1), quote: first }; + } + } + return { value }; +} + +function isExecutableName(value: string): boolean { + return !value.includes('/') && !value.includes('\\'); +} diff --git a/src/test/suite/rPathResolver.test.ts b/src/test/suite/rPathResolver.test.ts new file mode 100644 index 00000000..a533d832 --- /dev/null +++ b/src/test/suite/rPathResolver.test.ts @@ -0,0 +1,394 @@ +import * as assert from 'assert'; + +import { + ExecutableResolution, + findExecutableOnPath, + formatRPath, + resolveBackgroundR, + resolveConsoleR, + resolveSystemR, + RPathResolverDependencies, + selectWorkspaceFolder, + substitutePathVariables, +} from '../../rPathResolver'; + +interface DependencyHarness { + dependencies: RPathResolverDependencies; + executableLookups: string[]; + pathChecks: string[]; + getSystemRCalls: () => number; +} + +function createDependencies( + settings: Record, + executables: Record = {}, + existingPaths: readonly string[] = [], + systemR = '/system/R', + substituteVariables: (value: string) => string = value => value +): DependencyHarness { + const executableLookups: string[] = []; + const pathChecks: string[] = []; + let systemRCalls = 0; + return { + dependencies: { + getSetting: setting => settings[setting], + substituteVariables, + findExecutable: name => { + executableLookups.push(name); + return executables[name]; + }, + pathExists: value => { + pathChecks.push(value); + return existingPaths.includes(value); + }, + getSystemR: () => { + systemRCalls += 1; + return Promise.resolve(systemR || undefined); + }, + }, + executableLookups, + pathChecks, + getSystemRCalls: () => systemRCalls, + }; +} + +suite('R executable resolver', () => { + test('background precedence is canonical, legacy, then system', async () => { + const canonical = createDependencies( + { executablePath: '/canonical/R', 'rpath.linux': '/legacy/R' }, + {}, + ['/canonical/R', '/legacy/R'] + ); + assert.deepStrictEqual( + await resolveBackgroundR(canonical.dependencies, 'rpath.linux'), + { path: '/canonical/R', setting: 'executablePath', quote: undefined } + ); + assert.strictEqual(canonical.getSystemRCalls(), 0); + + const legacy = createDependencies( + { executablePath: '', 'rpath.linux': 'legacy-R' }, + { 'legacy-R': '/legacy/R' } + ); + assert.deepStrictEqual( + await resolveBackgroundR(legacy.dependencies, 'rpath.linux'), + { path: '/legacy/R', setting: 'rpath.linux', quote: undefined } + ); + assert.strictEqual(legacy.getSystemRCalls(), 0); + + const system = createDependencies({ executablePath: ' ', 'rpath.linux': '' }); + assert.deepStrictEqual( + await resolveBackgroundR(system.dependencies, 'rpath.linux'), + { path: '/system/R' } + ); + assert.strictEqual(system.getSystemRCalls(), 1); + }); + + test('an invalid explicit background setting does not fall through', async () => { + const harness = createDependencies( + { executablePath: 'missing-R', 'rpath.linux': '/legacy/R' }, + {}, + ['/legacy/R'] + ); + assert.deepStrictEqual( + await resolveBackgroundR(harness.dependencies, 'rpath.linux'), + { path: undefined, setting: 'executablePath', quote: undefined } + ); + assert.strictEqual(harness.getSystemRCalls(), 0); + assert.deepStrictEqual(harness.executableLookups, ['missing-R']); + }); + + test('an invalid explicit legacy background setting does not fall through', async () => { + const harness = createDependencies({ executablePath: '', 'rpath.linux': '/missing/R' }); + assert.deepStrictEqual( + await resolveBackgroundR(harness.dependencies, 'rpath.linux'), + { path: undefined, setting: 'rpath.linux', quote: undefined } + ); + assert.strictEqual(harness.getSystemRCalls(), 0); + }); + + test('overwrite setting retains highest background precedence', async () => { + const harness = createDependencies( + { override: '/override/R', executablePath: '/canonical/R', 'rpath.linux': '/legacy/R' }, + {}, + ['/override/R', '/canonical/R', '/legacy/R'] + ); + assert.deepStrictEqual( + await resolveBackgroundR(harness.dependencies, 'rpath.linux', 'override'), + { path: '/override/R', setting: 'override', quote: undefined } + ); + }); + + test('console precedence is canonical, legacy, explicit canonical background, then system', async () => { + const canonical = createDependencies( + { + consolePath: '/canonical/radian', + 'rterm.linux': '/legacy/radian', + executablePath: '/background/R', + }, + {}, + ['/canonical/radian', '/legacy/radian', '/background/R'] + ); + assert.deepStrictEqual( + await resolveConsoleR(canonical.dependencies, 'rterm.linux'), + { path: '/canonical/radian', setting: 'consolePath', quote: undefined } + ); + + const legacy = createDependencies( + { consolePath: '', 'rterm.linux': 'legacy-radian', executablePath: '/background/R' }, + { 'legacy-radian': '/legacy/radian' }, + ['/background/R'] + ); + assert.deepStrictEqual( + await resolveConsoleR(legacy.dependencies, 'rterm.linux'), + { path: '/legacy/radian', setting: 'rterm.linux', quote: undefined } + ); + + const background = createDependencies( + { consolePath: '', 'rterm.linux': '', executablePath: '/background/R' }, + {}, + ['/background/R'] + ); + assert.deepStrictEqual( + await resolveConsoleR(background.dependencies, 'rterm.linux'), + { path: '/background/R', setting: 'executablePath', quote: undefined } + ); + + const system = createDependencies({ consolePath: '', 'rterm.linux': '', executablePath: '' }); + assert.deepStrictEqual( + await resolveConsoleR(system.dependencies, 'rterm.linux'), + { path: '/system/R' } + ); + }); + + test('legacy background setting never feeds console fallback', async () => { + const harness = createDependencies({ + consolePath: '', + 'rterm.linux': '', + executablePath: '', + 'rpath.linux': '/legacy/background/R', + }); + assert.deepStrictEqual( + await resolveConsoleR(harness.dependencies, 'rterm.linux'), + { path: '/system/R' } + ); + assert.strictEqual(harness.getSystemRCalls(), 1); + }); + + test('invalid explicit console choices do not silently fall through', async () => { + const consoleHarness = createDependencies({ + consolePath: 'missing-console', + 'rterm.linux': '/legacy/radian', + executablePath: '/background/R', + }, {}, ['/legacy/radian', '/background/R']); + assert.deepStrictEqual( + await resolveConsoleR(consoleHarness.dependencies, 'rterm.linux'), + { path: undefined, setting: 'consolePath', quote: undefined } + ); + assert.strictEqual(consoleHarness.getSystemRCalls(), 0); + + const legacyHarness = createDependencies({ + consolePath: '', + 'rterm.linux': '/missing/console', + executablePath: '/background/R', + }, {}, ['/background/R']); + assert.deepStrictEqual( + await resolveConsoleR(legacyHarness.dependencies, 'rterm.linux'), + { path: undefined, setting: 'rterm.linux', quote: undefined } + ); + assert.strictEqual(legacyHarness.getSystemRCalls(), 0); + + const backgroundHarness = createDependencies({ + consolePath: '', + 'rterm.linux': '', + executablePath: 'missing-background', + }); + assert.deepStrictEqual( + await resolveConsoleR(backgroundHarness.dependencies, 'rterm.linux'), + { path: undefined, setting: 'executablePath', quote: undefined } + ); + assert.strictEqual(backgroundHarness.getSystemRCalls(), 0); + }); + + test('empty and quoted-empty values are unset', async () => { + const harness = createDependencies({ + consolePath: ' ', + 'rterm.linux': '\u0027\u0027', + executablePath: '', + }); + assert.deepStrictEqual( + await resolveConsoleR(harness.dependencies, 'rterm.linux'), + { path: '/system/R' } + ); + assert.strictEqual(harness.getSystemRCalls(), 1); + }); + + test('bare executable names resolve only through PATH lookup', async () => { + const harness = createDependencies( + { executablePath: 'custom-R', 'rpath.linux': '' }, + { 'custom-R': '/tools/custom-R' }, + ['/workspace/custom-R'] + ); + assert.strictEqual( + (await resolveBackgroundR(harness.dependencies, 'rpath.linux')).path, + '/tools/custom-R' + ); + assert.deepStrictEqual(harness.executableLookups, ['custom-R']); + assert.deepStrictEqual(harness.pathChecks, []); + }); + + test('r.consolePath bare arf resolves to its absolute path on PATH', async () => { + const harness = createDependencies( + { consolePath: 'arf', 'rterm.linux': '', executablePath: '' }, + { arf: '/opt/arf/bin/arf' } + ); + assert.deepStrictEqual( + await resolveConsoleR(harness.dependencies, 'rterm.linux'), + { path: '/opt/arf/bin/arf', setting: 'consolePath', quote: undefined } + ); + assert.deepStrictEqual(harness.executableLookups, ['arf']); + assert.strictEqual(harness.getSystemRCalls(), 0); + }); + + test('substituted absolute paths resolve without treating them as commands', async () => { + const harness = createDependencies( + { executablePath: '${workspaceFolder}/tools/R', 'rpath.linux': '' }, + {}, + ['/workspace/tools/R'], + '/system/R', + value => substitutePathVariables(value, { workspaceFolder: '/workspace' }) + ); + assert.strictEqual( + (await resolveBackgroundR(harness.dependencies, 'rpath.linux')).path, + '/workspace/tools/R' + ); + assert.deepStrictEqual(harness.pathChecks, ['/workspace/tools/R']); + assert.deepStrictEqual(harness.executableLookups, []); + }); + + test('unavailable workspace and file variables remain unresolved without crashing or falling through', async () => { + const harness = createDependencies( + { executablePath: '${fileWorkspaceFolder}/R', 'rpath.linux': '/legacy/R' }, + {}, + ['/legacy/R'], + '/system/R', + value => substitutePathVariables(value, {}) + ); + assert.deepStrictEqual( + await resolveBackgroundR(harness.dependencies, 'rpath.linux'), + { path: undefined, setting: 'executablePath', quote: undefined } + ); + assert.strictEqual(harness.getSystemRCalls(), 0); + }); + + test('all supported variables are substituted', () => { + assert.strictEqual( + substitutePathVariables( + '${userHome}|${workspaceFolder}|${fileWorkspaceFolder}|${fileDirname}', + { + userHome: '/home/user', + workspaceFolder: '/workspace/first', + fileWorkspaceFolder: '/workspace/second', + fileDirname: '/workspace/second/src', + } + ), + '/home/user|/workspace/first|/workspace/second|/workspace/second/src' + ); + assert.strictEqual(substitutePathVariables('${workspaceFolder}/R', {}), '${workspaceFolder}/R'); + }); + + test('workspace selection supports single-root, multi-root, no file, and no workspace', () => { + const first = { name: 'first' }; + const second = { name: 'second' }; + assert.strictEqual(selectWorkspaceFolder([first], undefined), first); + assert.strictEqual(selectWorkspaceFolder([first, second], second), second); + assert.strictEqual(selectWorkspaceFolder([first, second], undefined), first); + assert.strictEqual(selectWorkspaceFolder([], undefined), undefined); + assert.strictEqual(selectWorkspaceFolder(undefined, undefined), undefined); + }); + + test('quoted configured values are resolved and formatting preserves getRpath quote behavior', async () => { + const doubleQuoted = createDependencies( + { executablePath: '"/R path/bin/R"', 'rpath.linux': '' }, + {}, + ['/R path/bin/R'] + ); + const doubleResolution = await resolveBackgroundR(doubleQuoted.dependencies, 'rpath.linux'); + assert.strictEqual(doubleResolution.path, '/R path/bin/R'); + assert.strictEqual(formatRPath(doubleResolution, false, 'linux'), '/R path/bin/R'); + assert.strictEqual(formatRPath(doubleResolution, true, 'linux'), '"/R path/bin/R"'); + + const singleResolution: ExecutableResolution = { path: '/R path/bin/R', quote: `'` }; + assert.strictEqual(formatRPath(singleResolution, true, 'linux'), `'/R path/bin/R'`); + assert.strictEqual(formatRPath(singleResolution, true, 'win32'), '"/R path/bin/R"'); + assert.strictEqual(formatRPath({ path: '/R path/bin/R' }, true, 'linux'), '"/R path/bin/R"'); + }); + + test('console resolution returns an unquoted executable path', async () => { + const harness = createDependencies( + { consolePath: `'/R console/radian'`, 'rterm.linux': '', executablePath: '' }, + {}, + ['/R console/radian'] + ); + assert.strictEqual( + (await resolveConsoleR(harness.dependencies, 'rterm.linux')).path, + '/R console/radian' + ); + }); +}); + +suite('system R resolver', () => { + test('PATH lookup handles Unix and Windows executable names', () => { + const windowsPath = String.raw`C:\first;D:\second`; + const windowsR = String.raw`D:\second\R.exe`; + const windowsRDirectory = String.raw`C:\R`; + const windowsRWithExtension = String.raw`C:\R\R.exe`; + + assert.strictEqual( + findExecutableOnPath('R', 'linux', '/first:/second', value => value === '/second/R'), + '/second/R' + ); + assert.strictEqual( + findExecutableOnPath('R', 'win32', windowsPath, value => value === windowsR), + windowsR + ); + assert.strictEqual( + findExecutableOnPath('R.exe', 'win32', windowsRDirectory, value => value === windowsRWithExtension), + windowsRWithExtension + ); + }); + + test('PATH wins over the Windows registry', async () => { + const pathR = String.raw`C:\PATH\R.exe`; + let registryCalls = 0; + assert.strictEqual(await resolveSystemR({ + platform: 'win32', + findExecutable: () => pathR, + getWindowsInstallPath: () => { + registryCalls += 1; + return Promise.resolve(String.raw`C:\Registry\R`); + }, + }), pathR); + assert.strictEqual(registryCalls, 0); + }); + + test('Windows registry InstallPath supplies bin\\R.exe after PATH misses', async () => { + assert.strictEqual(await resolveSystemR({ + platform: 'win32', + findExecutable: () => undefined, + getWindowsInstallPath: () => Promise.resolve(String.raw`C:\Program Files\R\R-4.5.0`), + }), String.raw`C:\Program Files\R\R-4.5.0\bin\R.exe`); + }); + + test('non-Windows systems do not query the registry', async () => { + let registryCalls = 0; + assert.strictEqual(await resolveSystemR({ + platform: 'linux', + findExecutable: () => undefined, + getWindowsInstallPath: () => { + registryCalls += 1; + return Promise.resolve('/registry/R'); + }, + }), undefined); + assert.strictEqual(registryCalls, 0); + }); +}); diff --git a/src/util.ts b/src/util.ts index 9dc524fc..f9459038 100644 --- a/src/util.ts +++ b/src/util.ts @@ -9,79 +9,60 @@ import * as vscode from 'vscode'; import * as cp from 'child_process'; import { extensionContext } from './extension'; import { randomBytes } from 'crypto'; +import { + findExecutableOnPath, + formatRPath, + resolveBackgroundR, + resolveConsoleR, + resolveSystemR, + RPathResolverDependencies, + rConsolePathSetting, + rExecutablePathSetting, + selectWorkspaceFolder, + substitutePathVariables, +} from './rPathResolver'; export function config(): vscode.WorkspaceConfiguration { return vscode.workspace.getConfiguration('r'); } -function substituteVariable(str: string, key: string, getValue: () => string | undefined) { - if (str.includes(key)) { - const value = getValue(); - if (value) { - return str.replaceAll(key, value); - } - } - return str; -} - export function substituteVariables(str: string): string { - let result = str; - if (str.includes('${')) { - result = substituteVariable(result, '${userHome}', () => homedir()); - result = substituteVariable(result, '${workspaceFolder}', () => getCurrentWorkspaceFolder()?.uri.fsPath); - result = substituteVariable(result, '${fileWorkspaceFolder}', () => getActiveFileWorkspaceFolder()?.uri.fsPath); - result = substituteVariable(result, '${fileDirname}', () => { - const activeFilePath = vscode.window.activeTextEditor?.document.uri.fsPath; - if (activeFilePath) { - return path.dirname(activeFilePath); - } - }); + if (!str.includes('${')) { + return str; } - return result; + + const activeFilePath = vscode.window.activeTextEditor?.document.uri.fsPath; + return substitutePathVariables(str, { + userHome: homedir(), + workspaceFolder: getCurrentWorkspaceFolder()?.uri.fsPath, + fileWorkspaceFolder: getActiveFileWorkspaceFolder()?.uri.fsPath, + fileDirname: activeFilePath ? path.dirname(activeFilePath) : undefined, + }); } function getRfromEnvPath(platform: string, executableName: string = 'R') { - let splitChar = ':'; - let fileExtension = ''; - - if (platform === 'win32') { - splitChar = ';'; - fileExtension = '.exe'; - } - - const os_paths: string[] | string = process.env.PATH ? process.env.PATH.split(splitChar) : []; - for (const os_path of os_paths) { - const os_r_path: string = path.join(os_path, executableName + fileExtension); - if (fs.existsSync(os_r_path)) { - return os_r_path; - } - } - return ''; + return findExecutableOnPath(executableName, platform as NodeJS.Platform, process.env.PATH, fs.existsSync) ?? ''; } export async function getRpathFromSystem(): Promise { - - let rpath = ''; - const platform: string = process.platform; - - rpath ||= getRfromEnvPath(platform); - - if (!rpath && platform === 'win32') { - // Find path from registry - try { - const key = new winreg({ - hive: winreg.HKLM, - key: '\\Software\\R-Core\\R', - }); - const item: winreg.RegistryItem = await new Promise((c, e) => - key.get('InstallPath', (err, result) => err === null ? c(result) : e(err))); - rpath = path.join(item.value, 'bin', 'R.exe'); - } catch (e) { - rpath = ''; - } - } - - return rpath; + const rpath = await resolveSystemR({ + platform: process.platform, + findExecutable: name => getRfromEnvPath(process.platform, name) || undefined, + getWindowsInstallPath: async () => { + try { + const key = new winreg({ + hive: winreg.HKLM, + key: '\\Software\\R-Core\\R', + }); + const item: winreg.RegistryItem = await new Promise((c, e) => + key.get('InstallPath', (err, result) => err === null ? c(result) : e(err))); + return item.value; + } catch { + return undefined; + } + }, + }); + return rpath ?? ''; } export function getRPathConfigEntry(term: boolean = false): string { @@ -95,36 +76,17 @@ export function getRPathConfigEntry(term: boolean = false): string { } export async function getRpath(quote = false, overwriteConfig?: string): Promise { - let rpath: string | undefined = ''; - - // try the config entry specified in the function arg: - if (overwriteConfig) { - rpath = config().get(overwriteConfig); - } - - // try the os-specific config entry for the rpath: const configEntry = getRPathConfigEntry(); - rpath ||= config().get(configEntry); - rpath &&= substituteVariables(rpath); - - // read from path/registry: - rpath ||= await getRpathFromSystem(); - - // represent all invalid paths (undefined, '', null) as undefined: - rpath ||= undefined; + const resolution = await resolveBackgroundR( + getRPathResolverDependencies(), + configEntry, + overwriteConfig + ); + const rpath = formatRPath(resolution, quote, process.platform); if (!rpath) { // inform user about missing R path: - void vscode.window.showErrorMessage(`Cannot find R to use for help, package installation etc. Change setting r.${configEntry} to R path.`); - } else if (quote && /^[^'"].* .*[^'"]$/.exec(rpath)) { - // if requested and rpath contains spaces, add quotes: - rpath = `"${rpath}"`; - } else if (!quote) { - rpath = rpath.replace(/^"(.*)"$/, '$1'); - rpath = rpath.replace(/^'(.*)'$/, '$1'); - } else if (process.platform === 'win32' && /^'.* .*'$/.exec(rpath)) { - // replace single quotes with double quotes on windows - rpath = rpath.replace(/^'(.*)'$/, '"$1"'); + void vscode.window.showErrorMessage(`Cannot find R to use for help, package installation etc. Change setting r.${rExecutablePathSetting} to an R executable.`); } return rpath; @@ -132,35 +94,28 @@ export async function getRpath(quote = false, overwriteConfig?: string): Promise export async function getRterm(): Promise { const configEntry = getRPathConfigEntry(true); - let rpath = config().get(configEntry); - rpath &&= substituteVariables(rpath); - - if (!rpath) { - const platform: string = process.platform; - const preferredConsoles = config().get('rterm.preferredConsoles', ['R']); + const resolution = await resolveConsoleR(getRPathResolverDependencies(), configEntry); + const rpath = resolution.path; - for (const consoleName of preferredConsoles) { - if (!consoleName) { - continue; - } - rpath = getRfromEnvPath(platform, consoleName); - if (rpath) { - break; - } - } - } - - // Fall back to system R path if still not found - rpath ||= await getRpathFromSystem(); - - if (rpath !== '') { + if (rpath) { return rpath; } - void vscode.window.showErrorMessage(`Cannot find R for creating R terminal. Change setting r.${configEntry} to R path.`); + void vscode.window.showErrorMessage(`Cannot find R for creating R terminal. Change setting r.${rConsolePathSetting} to an R console executable.`); return undefined; } +function getRPathResolverDependencies(): RPathResolverDependencies { + const currentConfig = config(); + return { + getSetting: setting => currentConfig.get(setting), + substituteVariables, + findExecutable: name => getRfromEnvPath(process.platform, name) || undefined, + pathExists: fs.existsSync, + getSystemR: getRpathFromSystem, + }; +} + export function ToRStringLiteral(s: string, quote: string): string { if (s === undefined) { return 'NULL'; @@ -199,15 +154,11 @@ function getActiveFileWorkspaceFolder(): vscode.WorkspaceFolder | undefined { } export function getCurrentWorkspaceFolder(): vscode.WorkspaceFolder | undefined { - if (vscode.workspace.workspaceFolders !== undefined) { - if (vscode.workspace.workspaceFolders.length === 1) { - return vscode.workspace.workspaceFolders[0]; - } else if (vscode.workspace.workspaceFolders.length > 1) { - return getActiveFileWorkspaceFolder() || vscode.workspace.workspaceFolders[0]; - } - } - - return undefined; + const workspaceFolders = vscode.workspace.workspaceFolders; + const activeFileWorkspaceFolder = workspaceFolders && workspaceFolders.length > 1 + ? getActiveFileWorkspaceFolder() + : undefined; + return selectWorkspaceFolder(workspaceFolders, activeFileWorkspaceFolder); } // Drop-in replacement for fs-extra.readFile (), From 4548f0a22a2a799ea5314cd4b240186c1ceef398 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 19 Sep 2026 09:18:22 +0000 Subject: [PATCH 2/4] docs(config): document R executable migration --- CHANGELOG.md | 3 ++- src/rTerminal.ts | 4 ++-- src/test/suite/rPathResolver.test.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8217b37..19616dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ * feat: check sess package version and prompt for update * feat(session): implement file-based reconnection and suppress verbose logs * feat(plot): new `r.plot.backend` enum setting for finer-grained control of the preferred plotting backend, including integration with the lightweight `jgd` graphics device (default if installed). `r.plot.useHttpgd` is deprecated in favor of `r.plot.backend`; it remains supported for compatibility with existing configurations but will be removed in a future release. -* feat(term): new `r.rterm.preferredConsoles` enum setting enables for string-based R console selection (`"R"` (default), `"arf"`, and/or `"radian"`), automatically resolved against the user's `PATH`. `r.term.` is still respected (and prioritized) if provided. +* feat(r-path): add `r.executablePath` as the canonical setting for vanilla R used by background processes and `r.consolePath` for the interactive R console. Both accept an absolute or substituted path, or a bare executable name available on `PATH`; for example, `r.executablePath` can be the bare vanilla executable name `R`, while `r.consolePath` can be `arf` (or `radian`). When `r.consolePath` and the legacy `r.rterm.` setting are unset, an explicitly configured `r.executablePath` is also used for the console. The legacy `r.rpath.` settings never affect console selection. The legacy `r.rpath.` and `r.rterm.` settings are deprecated in favor of the canonical settings, remain supported for backward compatibility, and may be removed in a future release. * feat(dataview): keep one viewer per data name, refreshing the existing viewer on repeated `View()` calls * feat(dataview): load data rows on demand while scrolling, with support for Arrow and Polars DataFrames * feat(workspace): support recursive expansion of nested lists, environments, pairlists, S4 objects, and data frames @@ -29,6 +29,7 @@ * Remove the unused `r.workspaceViewer.showObjectSize` setting and obsolete object-size tooltip support * Remove obsolete `r.session.objectLengthLimit`, `r.session.objectTimeout`, and `r.session.levelOfObjectDetail` settings following the switch to on-demand workspace inspection +* Remove `r.helpPanel.rpath`, which was previously deprecated and no longer used by the extension ### Styling diff --git a/src/rTerminal.ts b/src/rTerminal.ts index ba7e3e07..5fa59bc0 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -217,10 +217,10 @@ export async function createRTerm(preserveshow?: boolean): Promise { void util.promptToInstallSessPackage(termOptions.cwd); const termPath = termOptions.shellPath; if(!termPath){ - void vscode.window.showErrorMessage('Could not find R path. Please check r.rterm and r.rpath setting.'); + void vscode.window.showErrorMessage('Could not find an R console executable. Please check the r.consolePath and r.executablePath settings.'); return false; } else if(!fs.existsSync(termPath)){ - void vscode.window.showErrorMessage(`Cannot find R client at ${termPath}. Please check r.rterm setting.`); + void vscode.window.showErrorMessage(`Cannot find R client at ${termPath}. Please check the r.consolePath setting.`); return false; } rTerm = vscode.window.createTerminal(termOptions); diff --git a/src/test/suite/rPathResolver.test.ts b/src/test/suite/rPathResolver.test.ts index a533d832..c9be1faf 100644 --- a/src/test/suite/rPathResolver.test.ts +++ b/src/test/suite/rPathResolver.test.ts @@ -371,7 +371,7 @@ suite('system R resolver', () => { assert.strictEqual(registryCalls, 0); }); - test('Windows registry InstallPath supplies bin\\R.exe after PATH misses', async () => { + test(String.raw`Windows registry InstallPath supplies bin\R.exe after PATH misses`, async () => { assert.strictEqual(await resolveSystemR({ platform: 'win32', findExecutable: () => undefined, From 1d0c5789a6bfdde9903d54c393eaba32118d3477 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 19 Sep 2026 10:00:43 +0000 Subject: [PATCH 3/4] feat(config): resolve R paths by workspace resource --- CHANGELOG.md | 2 +- src/cppProperties.ts | 4 +- src/languageService.ts | 20 +++++---- src/rPathResolver.ts | 32 +++++++++++++- src/rTerminal.ts | 10 +++-- src/rmarkdown/draft.ts | 2 +- src/rmarkdown/knit.ts | 2 +- src/rmarkdown/preview.ts | 3 +- src/tasks.ts | 14 +++---- src/test/suite/rPathResolver.test.ts | 52 +++++++++++++++++++++++ src/util.ts | 62 ++++++++++++++++++---------- 11 files changed, 155 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19616dd1..53d51f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ * feat: check sess package version and prompt for update * feat(session): implement file-based reconnection and suppress verbose logs * feat(plot): new `r.plot.backend` enum setting for finer-grained control of the preferred plotting backend, including integration with the lightweight `jgd` graphics device (default if installed). `r.plot.useHttpgd` is deprecated in favor of `r.plot.backend`; it remains supported for compatibility with existing configurations but will be removed in a future release. -* feat(r-path): add `r.executablePath` as the canonical setting for vanilla R used by background processes and `r.consolePath` for the interactive R console. Both accept an absolute or substituted path, or a bare executable name available on `PATH`; for example, `r.executablePath` can be the bare vanilla executable name `R`, while `r.consolePath` can be `arf` (or `radian`). When `r.consolePath` and the legacy `r.rterm.` setting are unset, an explicitly configured `r.executablePath` is also used for the console. The legacy `r.rpath.` settings never affect console selection. The legacy `r.rpath.` and `r.rterm.` settings are deprecated in favor of the canonical settings, remain supported for backward compatibility, and may be removed in a future release. +* feat(r-path): add `r.executablePath` as the canonical setting for vanilla R used by background processes and `r.consolePath` for the interactive R console. Both accept an absolute or substituted path, or a bare executable name available on `PATH`; for example, `r.executablePath` can be the bare vanilla executable name `R`, while `r.consolePath` can be `arf` (or `radian`). Path settings are resolved against the relevant workspace resource when one is available. When `r.consolePath` and the legacy `r.rterm.` setting are unset, an explicitly configured `r.executablePath` is also used for the console. The legacy `r.rpath.` settings never affect console selection. The legacy `r.rpath.` and `r.rterm.` settings are deprecated in favor of the canonical settings, remain supported for backward compatibility, and may be removed in a future release. * feat(dataview): keep one viewer per data name, refreshing the existing viewer on repeated `View()` calls * feat(dataview): load data rows on demand while scrolling, with support for Arrow and Polars DataFrames * feat(workspace): support recursive expansion of nested lists, environments, pairlists, S4 objects, and data frames diff --git a/src/cppProperties.ts b/src/cppProperties.ts index ef477ef1..22254567 100644 --- a/src/cppProperties.ts +++ b/src/cppProperties.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { window } from 'vscode'; +import { Uri, window } from 'vscode'; import { getRpath, getCurrentWorkspaceFolder, executeRCommand, createTempDir } from './util'; import { execSync } from 'child_process'; import { extensionContext } from './extension'; @@ -36,7 +36,7 @@ function platformChoose(win32: A, darwin: B, other: C): A | B | C { // See: https://code.visualstudio.com/docs/cpp/c-cpp-properties-schema-reference async function generateCppPropertiesProc(workspaceFolder: string) { - const rPath = await getRpath(); + const rPath = await getRpath(false, undefined, Uri.file(workspaceFolder)); if (!rPath) { return; } diff --git a/src/languageService.ts b/src/languageService.ts index c7776ecc..48439f29 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -7,7 +7,7 @@ import { URL } from 'url'; import * as fs from 'fs'; import { LanguageClient, LanguageClientOptions, StreamInfo, DocumentFilter, ErrorAction, CloseAction, RevealOutputChannelOn } from 'vscode-languageclient/node'; import { Disposable, workspace, Uri, TextDocument, WorkspaceConfiguration, OutputChannel, window, WorkspaceFolder } from 'vscode'; -import { DisposableProcess, getRLibPaths, getRpath, promptToInstallRPackage, spawn, substituteVariables } from './util'; +import { config, DisposableProcess, getRLibPaths, getRpath, promptToInstallRPackage, spawn, substituteVariables } from './util'; import { extensionContext } from './extension'; import { CommonOptions } from 'child_process'; @@ -57,13 +57,15 @@ export class LanguageService implements Disposable { } private async createClient(selector: DocumentFilter[], - cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel): Promise { + cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, + resource?: Uri): Promise { let client: LanguageClient; + const resourceConfig = config(resource); const debug = this.config.get('lsp.debug'); const useRenvLibPath = this.config.get('useRenvLibPath') ?? false; - const rPath = await getRpath() || ''; // TODO: Abort gracefully + const rPath = await getRpath(false, undefined, resource) || ''; // TODO: Abort gracefully if (debug) { console.log(`R path: ${rPath}`); } @@ -87,7 +89,7 @@ export class LanguageService implements Disposable { const rScriptPath = extensionContext.asAbsolutePath('R/languageServer.R'); const options = { cwd: cwd, env: env }; - const args = (this.config.get('lsp.args')?.map(substituteVariables) ?? []).concat( + const args = (resourceConfig.get('lsp.args')?.map(value => substituteVariables(value, resource)) ?? []).concat( '--silent', '--no-echo', '--no-save', @@ -227,7 +229,7 @@ export class LanguageService implements Disposable { { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, ]; const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), folder, this.outputChannel); + dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri); this.clients.set(key, client); this.initSet.delete(key); } @@ -245,7 +247,7 @@ export class LanguageService implements Disposable { { scheme: 'file', language: 'r', pattern: pattern }, { scheme: 'file', language: 'rmd', pattern: pattern }, ]; - const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel); + const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri); this.clients.set(key, client); this.initSet.delete(key); } @@ -261,7 +263,7 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel); + const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri); this.clients.set(key, client); this.initSet.delete(key); } @@ -277,7 +279,7 @@ export class LanguageService implements Disposable { { scheme: 'file', pattern: document.uri.fsPath }, ]; const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), undefined, this.outputChannel); + dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri); this.clients.set(key, client); this.initSet.delete(key); } @@ -351,7 +353,7 @@ export class LanguageService implements Disposable { const workspaceFolder = workspace.workspaceFolders?.[0]; const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); - const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel); + const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri); this.clients.set('global', client); } } diff --git a/src/rPathResolver.ts b/src/rPathResolver.ts index 71ea6231..487cc045 100644 --- a/src/rPathResolver.ts +++ b/src/rPathResolver.ts @@ -24,12 +24,38 @@ export interface RPathResolverDependencies { getSystemR: () => Promise; } +export interface ConfigurationReader { + get: (setting: string) => T | undefined; +} + +export interface RPathResolverAdapterOptions { + resource?: Resource; + getConfiguration: (resource?: Resource) => ConfigurationReader; + substituteVariables: (value: string, resource?: Resource) => string; + findExecutable: (name: string) => string | undefined; + pathExists: (value: string) => boolean; + getSystemR: () => Promise; +} + export interface SystemRResolverDependencies { platform: NodeJS.Platform; findExecutable: (name: string) => string | undefined; getWindowsInstallPath: () => Promise; } +export function createRPathResolverDependencies( + options: RPathResolverAdapterOptions +): RPathResolverDependencies { + const currentConfig = options.getConfiguration(options.resource); + return { + getSetting: setting => currentConfig.get(setting), + substituteVariables: value => options.substituteVariables(value, options.resource), + findExecutable: options.findExecutable, + pathExists: options.pathExists, + getSystemR: options.getSystemR, + }; +} + interface ConfiguredExecutableResolution extends ExecutableResolution { setting: string; } @@ -53,8 +79,12 @@ export function substitutePathVariables(value: string, variables: PathVariables) export function selectWorkspaceFolder( workspaceFolders: readonly T[] | undefined, - activeFileWorkspaceFolder: T | undefined + activeFileWorkspaceFolder: T | undefined, + resourceWorkspaceFolder?: T ): T | undefined { + if (resourceWorkspaceFolder) { + return resourceWorkspaceFolder; + } if (!workspaceFolders?.length) { return undefined; } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 5fa59bc0..650ba30a 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -183,9 +183,13 @@ export async function runFromLineToEnd(): Promise { import { getGlobalPipePath, writeSessionFile } from './session'; export async function makeTerminalOptions(): Promise { - const workspaceFolderPath = getCurrentWorkspaceFolder()?.uri.fsPath; - const termPath = await getRterm(); - const shellArgs: string[] = config().get('rterm.option')?.map(util.substituteVariables) || []; + const workspaceFolder = getCurrentWorkspaceFolder(); + const resource = workspaceFolder?.uri; + const workspaceFolderPath = resource?.fsPath; + const currentConfig = config(resource); + const termPath = await getRterm(resource); + const shellArgs: string[] = currentConfig.get('rterm.option') + ?.map(value => util.substituteVariables(value, resource)) || []; const termOptions: vscode.TerminalOptions = { name: 'R Interactive', shellPath: termPath, diff --git a/src/rmarkdown/draft.ts b/src/rmarkdown/draft.ts index 1c0b7c00..e2610404 100644 --- a/src/rmarkdown/draft.ts +++ b/src/rmarkdown/draft.ts @@ -20,7 +20,7 @@ interface TemplateItem extends QuickPickItem { async function getTemplateItems(cwd: string): Promise { const lim = '---vsc---'; - const rPath = await getRpath(); + const rPath = await getRpath(false, undefined, Uri.file(cwd)); if (!rPath) { return undefined; } diff --git a/src/rmarkdown/knit.ts b/src/rmarkdown/knit.ts index 1268f18b..c9efb453 100644 --- a/src/rmarkdown/knit.ts +++ b/src/rmarkdown/knit.ts @@ -41,7 +41,7 @@ export class RMarkdownKnitManager extends RMarkdownManager { if (!knitCommand) { return; } - this.rPath = await util.getRpath(); + this.rPath = await util.getRpath(false, undefined, vscode.Uri.file(docPath)); const lim = '<<>>'; const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'gms'); diff --git a/src/rmarkdown/preview.ts b/src/rmarkdown/preview.ts index b9d5984d..cf684845 100644 --- a/src/rmarkdown/preview.ts +++ b/src/rmarkdown/preview.ts @@ -311,7 +311,7 @@ export class RMarkdownPreviewManager extends RMarkdownManager { private async previewDocument(filePath: string, fileName?: string, viewer?: vscode.ViewColumn, currentViewColumn?: vscode.ViewColumn): Promise { const knitWorkingDir = this.getKnitDir(knitDir, filePath); const knitWorkingDirText = knitWorkingDir ? `${knitWorkingDir}` : ''; - this.rPath = await getRpath(); + this.rPath = await getRpath(false, undefined, vscode.Uri.file(filePath)); const lim = '<<>>'; const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'ms'); @@ -425,4 +425,3 @@ export class RMarkdownPreviewManager extends RMarkdownManager { void preview.refreshContent(this.useCodeTheme); } } - diff --git a/src/tasks.ts b/src/tasks.ts index 1062ee43..a611eea1 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -140,14 +140,13 @@ export class RTaskProvider implements vscode.TaskProvider { } const tasks: vscode.Task[] = []; - const rPath = await getRpath(false); - if (!rPath) { - return []; - } - for (const folder of folders) { const isRPackage = fs.existsSync(path.join(folder.uri.fsPath, 'DESCRIPTION')); if (isRPackage) { + const rPath = await getRpath(false, undefined, folder.uri); + if (!rPath) { + continue; + } for (const rtask of rtasks) { const task = asRTask(rPath, folder, rtask); tasks.push(task); @@ -163,10 +162,11 @@ export class RTaskProvider implements vscode.TaskProvider { group: task.group, name: task.name }; - const rPath = await getRpath(false); + const folder = task.scope && typeof task.scope !== 'number' ? task.scope : undefined; + const rPath = await getRpath(false, undefined, folder?.uri); if (!rPath) { throw 'R path not set.'; } - return asRTask(rPath, vscode.TaskScope.Workspace, taskInfo); + return asRTask(rPath, folder ?? vscode.TaskScope.Workspace, taskInfo); } } diff --git a/src/test/suite/rPathResolver.test.ts b/src/test/suite/rPathResolver.test.ts index c9be1faf..da1a8557 100644 --- a/src/test/suite/rPathResolver.test.ts +++ b/src/test/suite/rPathResolver.test.ts @@ -1,6 +1,7 @@ import * as assert from 'assert'; import { + createRPathResolverDependencies, ExecutableResolution, findExecutableOnPath, formatRPath, @@ -302,10 +303,61 @@ suite('R executable resolver', () => { assert.strictEqual(selectWorkspaceFolder([first], undefined), first); assert.strictEqual(selectWorkspaceFolder([first, second], second), second); assert.strictEqual(selectWorkspaceFolder([first, second], undefined), first); + assert.strictEqual(selectWorkspaceFolder([first, second], second, first), first); assert.strictEqual(selectWorkspaceFolder([], undefined), undefined); assert.strictEqual(selectWorkspaceFolder(undefined, undefined), undefined); }); + test('dependency adapter reads configuration for the requested resource', async () => { + type Resource = 'workspace-a' | 'workspace-b'; + const settings: Record> = { + 'workspace-a': { executablePath: '/workspace-a/R' }, + 'workspace-b': { executablePath: '/workspace-b/R' }, + default: { executablePath: '/default/R' }, + }; + const existingPaths = new Set(['/workspace-a/R', '/workspace-b/R', '/default/R']); + const dependencies = (resource?: Resource) => createRPathResolverDependencies({ + resource, + getConfiguration: requestedResource => ({ + get: (setting: string) => settings[requestedResource ?? 'default'][setting] as T | undefined, + }), + substituteVariables: value => value, + findExecutable: () => undefined, + pathExists: value => existingPaths.has(value), + getSystemR: () => Promise.resolve(undefined), + }); + + assert.strictEqual((await resolveBackgroundR(dependencies('workspace-a'), 'rpath.linux')).path, '/workspace-a/R'); + assert.strictEqual((await resolveBackgroundR(dependencies('workspace-b'), 'rpath.linux')).path, '/workspace-b/R'); + assert.strictEqual((await resolveBackgroundR(dependencies(), 'rpath.linux')).path, '/default/R'); + }); + + test('dependency adapter substitutes workspaceFolder for the requested resource', async () => { + type Resource = 'workspace-a' | 'workspace-b'; + const workspaceFolders: Record = { + 'workspace-a': '/workspace-a', + 'workspace-b': '/workspace-b', + default: '/active-workspace', + }; + const existingPaths = new Set(['/workspace-a/R', '/workspace-b/R', '/active-workspace/R']); + const dependencies = (resource?: Resource) => createRPathResolverDependencies({ + resource, + getConfiguration: () => ({ + get: (setting: string) => (setting === 'executablePath' ? '${workspaceFolder}/R' : undefined) as T | undefined, + }), + substituteVariables: (value, requestedResource) => substitutePathVariables(value, { + workspaceFolder: workspaceFolders[requestedResource ?? 'default'], + }), + findExecutable: () => undefined, + pathExists: value => existingPaths.has(value), + getSystemR: () => Promise.resolve(undefined), + }); + + assert.strictEqual((await resolveBackgroundR(dependencies('workspace-a'), 'rpath.linux')).path, '/workspace-a/R'); + assert.strictEqual((await resolveBackgroundR(dependencies('workspace-b'), 'rpath.linux')).path, '/workspace-b/R'); + assert.strictEqual((await resolveBackgroundR(dependencies(), 'rpath.linux')).path, '/active-workspace/R'); + }); + test('quoted configured values are resolved and formatting preserves getRpath quote behavior', async () => { const doubleQuoted = createDependencies( { executablePath: '"/R path/bin/R"', 'rpath.linux': '' }, diff --git a/src/util.ts b/src/util.ts index f9459038..598aa25e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -10,23 +10,23 @@ import * as cp from 'child_process'; import { extensionContext } from './extension'; import { randomBytes } from 'crypto'; import { + createRPathResolverDependencies, findExecutableOnPath, formatRPath, resolveBackgroundR, resolveConsoleR, resolveSystemR, - RPathResolverDependencies, rConsolePathSetting, rExecutablePathSetting, selectWorkspaceFolder, substitutePathVariables, } from './rPathResolver'; -export function config(): vscode.WorkspaceConfiguration { - return vscode.workspace.getConfiguration('r'); +export function config(resource?: vscode.Uri): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration('r', resource); } -export function substituteVariables(str: string): string { +export function substituteVariables(str: string, resource?: vscode.Uri): string { if (!str.includes('${')) { return str; } @@ -34,7 +34,7 @@ export function substituteVariables(str: string): string { const activeFilePath = vscode.window.activeTextEditor?.document.uri.fsPath; return substitutePathVariables(str, { userHome: homedir(), - workspaceFolder: getCurrentWorkspaceFolder()?.uri.fsPath, + workspaceFolder: getCurrentWorkspaceFolder(resource)?.uri.fsPath, fileWorkspaceFolder: getActiveFileWorkspaceFolder()?.uri.fsPath, fileDirname: activeFilePath ? path.dirname(activeFilePath) : undefined, }); @@ -75,10 +75,14 @@ export function getRPathConfigEntry(term: boolean = false): string { return `${trunc}.${platform}`; } -export async function getRpath(quote = false, overwriteConfig?: string): Promise { +export async function getRpath( + quote = false, + overwriteConfig?: string, + resource?: vscode.Uri +): Promise { const configEntry = getRPathConfigEntry(); const resolution = await resolveBackgroundR( - getRPathResolverDependencies(), + getRPathResolverDependencies(resource), configEntry, overwriteConfig ); @@ -92,9 +96,9 @@ export async function getRpath(quote = false, overwriteConfig?: string): Promise return rpath; } -export async function getRterm(): Promise { +export async function getRterm(resource?: vscode.Uri): Promise { const configEntry = getRPathConfigEntry(true); - const resolution = await resolveConsoleR(getRPathResolverDependencies(), configEntry); + const resolution = await resolveConsoleR(getRPathResolverDependencies(resource), configEntry); const rpath = resolution.path; if (rpath) { @@ -105,15 +109,15 @@ export async function getRterm(): Promise { return undefined; } -function getRPathResolverDependencies(): RPathResolverDependencies { - const currentConfig = config(); - return { - getSetting: setting => currentConfig.get(setting), +function getRPathResolverDependencies(resource?: vscode.Uri) { + return createRPathResolverDependencies({ + resource, + getConfiguration: config, substituteVariables, findExecutable: name => getRfromEnvPath(process.platform, name) || undefined, pathExists: fs.existsSync, getSystemR: getRpathFromSystem, - }; + }); } export function ToRStringLiteral(s: string, quote: string): string { @@ -153,12 +157,13 @@ function getActiveFileWorkspaceFolder(): vscode.WorkspaceFolder | undefined { } } -export function getCurrentWorkspaceFolder(): vscode.WorkspaceFolder | undefined { +export function getCurrentWorkspaceFolder(resource?: vscode.Uri): vscode.WorkspaceFolder | undefined { const workspaceFolders = vscode.workspace.workspaceFolders; - const activeFileWorkspaceFolder = workspaceFolders && workspaceFolders.length > 1 + const resourceWorkspaceFolder = resource ? vscode.workspace.getWorkspaceFolder(resource) : undefined; + const activeFileWorkspaceFolder = !resourceWorkspaceFolder && workspaceFolders && workspaceFolders.length > 1 ? getActiveFileWorkspaceFolder() : undefined; - return selectWorkspaceFolder(workspaceFolders, activeFileWorkspaceFolder); + return selectWorkspaceFolder(workspaceFolders, activeFileWorkspaceFolder, resourceWorkspaceFolder); } // Drop-in replacement for fs-extra.readFile (), @@ -314,7 +319,9 @@ export function compareVersions(v1: string, v2: string): number { } export function getRLibPaths(): string | undefined { - return config().get('libPaths')?.map(substituteVariables).join('\n'); + return config().get('libPaths') + ?.map(value => substituteVariables(value)) + .join('\n'); } // executes an R command returns its output to stdout @@ -325,7 +332,8 @@ export function getRLibPaths(): string | undefined { // Single quotes are ok. // export async function executeRCommand(rCommand: string, cwd?: string | URL, fallback?: string | ((e: Error) => string)): Promise { - const rPath = await getRpath(); + const resource = resourceFromCwd(cwd); + const rPath = await getRpath(false, undefined, resource); if (!rPath) { return undefined; } @@ -369,6 +377,16 @@ export async function executeRCommand(rCommand: string, cwd?: string | URL, fall return ret; } +function resourceFromCwd(cwd?: string | URL | vscode.Uri): vscode.Uri | undefined { + if (!cwd) { + return undefined; + } + if (cwd instanceof vscode.Uri) { + return cwd; + } + return typeof cwd === 'string' ? vscode.Uri.file(cwd) : vscode.Uri.parse(cwd.toString()); +} + // This class is a wrapper around Map that implements vscode.Memento // Can be used in place of vscode.ExtensionContext.globalState or .workspaceState when no caching is desired @@ -523,6 +541,7 @@ export async function spawnAsync(command: string, args?: ReadonlyArray, * @returns a boolean Promise */ export async function promptToInstallRPackage(name: string, section: string, cwd: string | URL, installMsg?: string, postInstallMsg?: string): Promise { + const resource = resourceFromCwd(cwd); const _config = config(); const prompt = _config.get(section); if (!prompt) { @@ -535,7 +554,7 @@ export async function promptToInstallRPackage(name: string, section: string, cwd .then(async function (select) { if (select === 'Yes') { const repo = await getCranUrl('', cwd); - const rPath = await getRpath(); + const rPath = await getRpath(false, undefined, resource); if (!rPath) { void vscode.window.showErrorMessage('R path not set', 'OK'); return; @@ -560,6 +579,7 @@ export async function promptToInstallSessPackage( _getRPackageVersion = getRPackageVersion, _readFileSyncSafe = readFileSyncSafe ): Promise { + const resource = resourceFromCwd(cwd); const activeConfig = _config(); const sessionWatcher = activeConfig.get('sessionWatcher'); if (!sessionWatcher) { @@ -590,7 +610,7 @@ export async function promptToInstallSessPackage( await vscode.window.showErrorMessage(installMsg, 'Yes', 'No') .then(async function (select) { if (select === 'Yes') { - const rPath = await getRpath(); + const rPath = await getRpath(false, undefined, resource); if (!rPath) { void vscode.window.showErrorMessage('R path not set', 'OK'); return; From 733dbd3929b1b18e33c508d4433f855862af00e3 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Mon, 21 Sep 2026 09:31:31 +0000 Subject: [PATCH 4/4] fix(config): warn about legacy R Debugger settings --- src/extension.ts | 8 ++ src/rDebuggerCompatibility.ts | 44 ++++++++++ src/test/suite/rDebuggerCompatibility.test.ts | 81 +++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 src/rDebuggerCompatibility.ts create mode 100644 src/test/suite/rDebuggerCompatibility.test.ts diff --git a/src/extension.ts b/src/extension.ts index 68cc595d..038d82b0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,6 +23,7 @@ import * as plotViewer from './plotViewer'; import { PlotManager } from './plotViewer/types'; import * as languageService from './languageService'; import { RTaskProvider } from './tasks'; +import { showRDebuggerCompatibilityWarningOnce } from './rDebuggerCompatibility'; // global objects used in other files @@ -39,6 +40,13 @@ export let sessionStatusBarItem: vscode.StatusBarItem | undefined = undefined; // Called (once) when the extension is activated export async function activate(context: vscode.ExtensionContext): Promise { + const rDebugger = vscode.extensions.getExtension('RDebugger.r-debugger'); + if (rDebugger) { + void showRDebuggerCompatibilityWarningOnce(context.globalState, rDebugger.packageJSON, message => + vscode.window.showWarningMessage(message) + ); + } + if (vscode.extensions.getExtension('mikhail-arkhipov.r')) { void vscode.window.showInformationMessage('The R Tools (Mikhail-Arkhipov.r) extension is enabled and will have conflicts with vscode-R. To use vscode-R, please disable or uninstall the extension.'); void vscode.commands.executeCommand('workbench.extensions.search', '@installed R Tools'); diff --git a/src/rDebuggerCompatibility.ts b/src/rDebuggerCompatibility.ts new file mode 100644 index 00000000..00e3cc68 --- /dev/null +++ b/src/rDebuggerCompatibility.ts @@ -0,0 +1,44 @@ +import * as vscode from 'vscode'; + +export const rDebuggerCompatibilityWarningKey = 'rDebuggerLegacyRPathCompatibilityWarning.v1'; + +const legacyRPathSettings = [ + 'r.rpath.windows', + 'r.rpath.mac', + 'r.rpath.linux', +]; + +const warningMessage = 'R Debugger still registers the legacy `r.rpath.` setting. Until it is updated, keep that setting and keep it in sync with `r.executablePath` so both extensions use the same R installation.'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function registersLegacyRPathSetting(packageJSON: unknown): boolean { + if (!isRecord(packageJSON) || !isRecord(packageJSON.contributes)) { + return false; + } + + const configuration = packageJSON.contributes.configuration; + const entries = Array.isArray(configuration) ? configuration : [configuration]; + return entries.some(entry => { + const properties = isRecord(entry) ? entry.properties : undefined; + return isRecord(properties) && legacyRPathSettings.some(setting => + Object.prototype.hasOwnProperty.call(properties, setting) + ); + }); +} + +export async function showRDebuggerCompatibilityWarningOnce( + globalState: vscode.Memento, + packageJSON: unknown, + showWarning: (message: string) => unknown +): Promise { + if (!registersLegacyRPathSetting(packageJSON) || globalState.get(rDebuggerCompatibilityWarningKey)) { + return false; + } + + await globalState.update(rDebuggerCompatibilityWarningKey, true); + void showWarning(warningMessage); + return true; +} diff --git a/src/test/suite/rDebuggerCompatibility.test.ts b/src/test/suite/rDebuggerCompatibility.test.ts new file mode 100644 index 00000000..a99630ba --- /dev/null +++ b/src/test/suite/rDebuggerCompatibility.test.ts @@ -0,0 +1,81 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +import { + registersLegacyRPathSetting, + rDebuggerCompatibilityWarningKey, + showRDebuggerCompatibilityWarningOnce, +} from '../../rDebuggerCompatibility'; + +suite('R Debugger compatibility warning', () => { + test('detects legacy settings in an object configuration', () => { + assert.strictEqual(registersLegacyRPathSetting({ + contributes: { + configuration: { + properties: { 'r.rpath.windows': { type: 'string' } }, + }, + }, + }), true); + }); + + test('detects legacy settings in an array configuration', () => { + assert.strictEqual(registersLegacyRPathSetting({ + contributes: { + configuration: [ + { properties: { unrelated: { type: 'string' } } }, + { properties: { 'r.rpath.mac': { type: 'string' } } }, + ], + }, + }), true); + }); + + test('ignores malformed configuration and unrelated or inherited properties', () => { + const inheritedProperties = Object.create({ 'r.rpath.linux': { type: 'string' } }) as Record; + assert.strictEqual(registersLegacyRPathSetting(undefined), false); + assert.strictEqual(registersLegacyRPathSetting({ contributes: { configuration: null } }), false); + assert.strictEqual(registersLegacyRPathSetting({ + contributes: { configuration: { properties: { 'r.rpath': { type: 'string' } } } }, + }), false); + assert.strictEqual(registersLegacyRPathSetting({ + contributes: { configuration: { properties: inheritedProperties } }, + }), false); + }); + + test('shows the warning once and stores its versioned global state key', async () => { + let warningWasShown: boolean | undefined; + const globalState = { + get: (key: string) => key === rDebuggerCompatibilityWarningKey ? warningWasShown as T | undefined : undefined, + update: (key: string, value: unknown) => Promise.resolve().then(() => { + assert.strictEqual(key, rDebuggerCompatibilityWarningKey); + warningWasShown = value as boolean; + }), + } as unknown as vscode.Memento; + const messages: string[] = []; + const packageJSON = { + contributes: { configuration: { properties: { 'r.rpath.linux': { type: 'string' } } } }, + }; + + assert.strictEqual(await showRDebuggerCompatibilityWarningOnce(globalState, packageJSON, message => messages.push(message)), true); + assert.strictEqual(await showRDebuggerCompatibilityWarningOnce(globalState, packageJSON, message => messages.push(message)), false); + assert.strictEqual(warningWasShown, true); + assert.strictEqual(messages.length, 1); + assert.match(messages[0], /keep.*in sync with `r\.executablePath`/); + }); + + test('does not store state or warn when the extension has no legacy setting', async () => { + let updateCalled = false; + const globalState = { + get: () => undefined, + update: () => Promise.resolve().then(() => { updateCalled = true; }), + } as unknown as vscode.Memento; + let warningCalled = false; + + assert.strictEqual(await showRDebuggerCompatibilityWarningOnce( + globalState, + { contributes: { configuration: { properties: { 'r.executablePath': { type: 'string' } } } } }, + () => { warningCalled = true; } + ), false); + assert.strictEqual(updateCalled, false); + assert.strictEqual(warningCalled, false); + }); +});