diff --git a/.vscode/settings.json b/.vscode/settings.json index 6bff3e86f..d39fbe699 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,6 @@ "typescript.tsdk": "./node_modules/typescript/lib", // we want to use the TS server from our node_modules folder to control its version "r.lsp.diagnostics": true, "editor.codeActionsOnSave": { - "source.fixAll.markdownlint": true + "source.fixAll.markdownlint": "explicit" } } \ No newline at end of file diff --git a/package.json b/package.json index 554eee113..38fbf3357 100644 --- a/package.json +++ b/package.json @@ -323,6 +323,11 @@ } ], "commands": [ + { + "category": "R", + "command": "r.setExecutable", + "title": "Select executable" + }, { "command": "r.workspaceViewer.refreshEntry", "title": "Manual Refresh", @@ -1428,32 +1433,38 @@ "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.", + "scope": "machine-overridable" }, "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.", + "scope": "machine-overridable" }, "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.", + "scope": "machine-overridable" }, "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 radian etc. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "scope": "machine-overridable" }, "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 radian etc. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "scope": "machine-overridable" }, "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 radian etc. Some variables defined in such as `${userHome}`, `${workspaceFolder}`, `${fileWorkspaceFolder}`, and `${fileDirname}` are supported.", + "scope": "machine-overridable" }, "r.rterm.option": { "type": "array", @@ -1474,7 +1485,7 @@ "default": [], "markdownDescription": "Additional library paths to launch R background processes (R languageserver, help server, etc.). These paths will be appended to `.libPaths()` on process startup. It could be useful for projects with [renv](https://rstudio.github.io/renv/index.html) enabled." }, - "r.useRenvLibPath" : { + "r.useRenvLibPath": { "type": "boolean", "default": false, "markdownDescription": "Use renv library paths to launch R background processes (R languageserver, help server, etc.)." @@ -2173,4 +2184,4 @@ "vsls": "^1.0.4753", "winreg": "^1.2.4" } -} +} \ No newline at end of file diff --git a/src/cppProperties.ts b/src/cppProperties.ts index ef477ef1b..379977c3c 100644 --- a/src/cppProperties.ts +++ b/src/cppProperties.ts @@ -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 = getRpath(); if (!rPath) { return; } diff --git a/src/executables/TODO.md b/src/executables/TODO.md new file mode 100644 index 000000000..360e201ef --- /dev/null +++ b/src/executables/TODO.md @@ -0,0 +1,5 @@ +# PR TODO + +- cleanup todos +- services when changing rpath + - very buggy right now diff --git a/src/executables/index.ts b/src/executables/index.ts new file mode 100644 index 000000000..d36fe9f58 --- /dev/null +++ b/src/executables/index.ts @@ -0,0 +1,84 @@ +'use strict'; + +import * as vscode from 'vscode'; + +import { ExecutableStatusItem, ExecutableQuickPick } from './ui'; +import { RExecutableService, RExecutableType, WorkspaceExecutableEvent } from './service'; +import { extensionContext } from '../extension'; + +export * from './virtual'; +export * from './util'; +export { RExecutableType, VirtualRExecutableType } from './service'; + +// super class that manages relevant sub classes +export class RExecutableManager { + private readonly executableService: RExecutableService; + private statusBar: ExecutableStatusItem; + private quickPick: ExecutableQuickPick; + + private constructor(service: RExecutableService) { + this.executableService = service; + this.statusBar = new ExecutableStatusItem(this.executableService); + this.quickPick = new ExecutableQuickPick(this.executableService); + extensionContext.subscriptions.push( + this.onDidChangeActiveExecutable(() => this.reload()), + vscode.window.onDidChangeActiveTextEditor((e: vscode.TextEditor | undefined) => { + if (e?.document) {this.reload();} + }), + this.executableService, + this.statusBar + ); + this.reload(); + } + + static async initialize(): Promise { + const executableService = await RExecutableService.initialize(); + return new this(executableService); + } + + public get executableQuickPick(): ExecutableQuickPick { + return this.quickPick; + } + + public get languageStatusItem(): ExecutableStatusItem { + return this.statusBar; + } + + public get activeExecutablePath(): string | undefined { + return this.executableService.activeExecutable?.rBin; + } + + /** + * Get the associated R executable for a given working directory path + * @param workingDir + * @returns + */ + public getExecutablePath(workingDir: string): string | undefined { + return this.executableService.getWorkspaceExecutable(workingDir)?.rBin; + } + + public getExecutableFromPath(rpath: string): RExecutableType | undefined { + return this.executableService.executableFactory.create(rpath); + } + + public get activeExecutable(): RExecutableType | undefined { + return this.executableService.activeExecutable; + } + + public get onDidChangeActiveExecutable(): vscode.Event { + return this.executableService.onDidChangeActiveExecutable; + } + + public get onDidChangeWorkspaceExecutable(): vscode.Event { + return this.executableService.onDidChangeWorkspaceExecutable; + } + + /** + * @description + * Orders a refresh of the executable manager, causing a refresh of the language status bar item + * @memberof RExecutableManager + */ + public reload(): void { + this.statusBar.refresh(); + } +} diff --git a/src/executables/service/class.ts b/src/executables/service/class.ts new file mode 100644 index 000000000..e2b5c5283 --- /dev/null +++ b/src/executables/service/class.ts @@ -0,0 +1,105 @@ +'use strict'; + +import { getRDetailsFromPath } from '../util'; +import { RExecutableRegistry } from './registry'; +import { RExecutableType } from './types'; +import { isCondaInstallation, isMambaInstallation, condaName, getRDetailsFromCondaMetaHistory } from '../virtual'; + +/** + * Creates and caches instances of RExecutableType + * based on the provided executable path. + */ +export class RExecutableFactory { + private readonly registry: RExecutableRegistry; + + constructor (registry: RExecutableRegistry) { + this.registry = registry; + } + + public create(executablePath: string): RExecutableType { + const cachedExec = [...this.registry.executables.values()].find((v) => v.rBin === executablePath); + if (cachedExec) { + return cachedExec; + } else { + let executable: AbstractRExecutable; + if (isCondaInstallation(executablePath)) { + executable = new CondaVirtualRExecutable(executablePath); + } else if (isMambaInstallation(executablePath)) { + executable = new MambaVirtualRExecutable(executablePath); + } else { + executable = new RExecutable(executablePath); + } + this.registry.addExecutable(executable); + return executable; + } + } +} + +export abstract class AbstractRExecutable { + protected _rBin!: string; + protected _rVersion!: string; + protected _rArch!: string; + public get rBin(): string { + return this._rBin; + } + + public get rVersion(): string { + return this._rVersion; + } + + public get rArch(): string { + return this._rArch; + } + public abstract tooltip: string; +} + + +export class RExecutable extends AbstractRExecutable { + constructor (executablePath: string) { + super(); + const details = getRDetailsFromPath(executablePath); + this._rBin = executablePath; + this._rVersion = details.version; + this._rArch = details.arch; + } + + public get tooltip(): string { + if (this.rVersion && this.rArch) { + return `R ${this.rVersion} ${this.rArch}`; + } + return `$(error) R`; + } +} + +export abstract class AbstractVirtualRExecutable extends AbstractRExecutable { + protected _name!: string; + public get name(): string { + return this._name; + } + public get tooltip(): string { + if (this.rVersion && this.rArch) { + return `R ${this.rVersion} ${this.rArch} ('${this.name}')`; + } + return `$(error) '${this.name}'`; + } +} + +export class CondaVirtualRExecutable extends AbstractVirtualRExecutable { + constructor (executablePath: string) { + super(); + this._name = condaName(executablePath); + const details = getRDetailsFromCondaMetaHistory(executablePath); + this._rVersion = details?.version ?? ''; + this._rArch = details?.arch ?? ''; + this._rBin = executablePath; + } +} + +// TODO + +export class MambaVirtualRExecutable extends AbstractVirtualRExecutable { + constructor (executablePath: string) { + super(); + } +} + diff --git a/src/executables/service/index.ts b/src/executables/service/index.ts new file mode 100644 index 000000000..1aa280f95 --- /dev/null +++ b/src/executables/service/index.ts @@ -0,0 +1,228 @@ +'use strict'; + +import * as vscode from 'vscode'; + +import { getConfigPathWithSubstitution, validateRExecutablePath } from '../util'; +import { RExecutableFactory } from './class'; +import { getCurrentWorkspaceFolder } from '../../util'; +import { RExecutablePathStorage } from './pathStorage'; +import { RExecutableRegistry } from './registry'; +import { TAbstractLocatorService, LocatorServiceFactory } from './locator'; +import { RExecutableType, WorkspaceExecutableEvent } from './types'; +import { getRenvVersion } from '../virtual/renv'; +import { homeExtDir } from '../../extension'; + +export * from './types'; +export * from './class'; + +/** + * @description + * @export + * @class RExecutableService + * @implements {vscode.Disposable} + */ +export class RExecutableService implements vscode.Disposable { + public executableFactory: RExecutableFactory; + public executablePathLocator: TAbstractLocatorService; + private executableStorage: RExecutablePathStorage; + private executableRegistry: RExecutableRegistry; + private executableEmitter: vscode.EventEmitter; + private workspaceEmitter: vscode.EventEmitter; + private workspaceExecutables: Map; + + public readonly ready!: Thenable; + + /** + * Creates an instance of RExecutableService. + * @memberof RExecutableService + */ + private constructor(locator: TAbstractLocatorService) { + this.executablePathLocator = locator; + this.executableRegistry = new RExecutableRegistry(); + this.executableStorage = new RExecutablePathStorage(); + this.executableFactory = new RExecutableFactory(this.executableRegistry); + this.workspaceExecutables = new Map(); + this.executableEmitter = new vscode.EventEmitter(); + this.workspaceEmitter = new vscode.EventEmitter(); + this.executablePathLocator.executablePaths.forEach((path) => { + this.executableFactory.create(path); + }); + + this.selectViableExecutables(); + } + + static async initialize(): Promise { + const locator = LocatorServiceFactory.getLocator(); + await locator.refreshPaths(); + return new this(locator); + } + + /** + * @description + * Get a list of all registered executables + * @readonly + * @type {Set} + * @memberof RExecutableService + */ + public get executables(): Set { + return this.executableRegistry.executables; + } + + /** + * @description + * @memberof RExecutableService + */ + public set activeExecutable(executable: RExecutableType | undefined) { + const currentWorkspace = getCurrentWorkspaceFolder()?.uri?.fsPath; + if (currentWorkspace) { + if (executable === undefined) { + this.workspaceExecutables.delete(currentWorkspace); + this.executableStorage.setExecutablePath(currentWorkspace, undefined); + console.log('[RExecutableService] executable cleared'); + this.executableEmitter.fire(undefined); + } else if (this.activeExecutable !== executable) { + this.workspaceExecutables.set(currentWorkspace, executable); + this.executableStorage.setExecutablePath(currentWorkspace, executable.rBin); + console.log('[RExecutableService] executable changed'); + this.executableEmitter.fire(executable); + } + } + } + + /** + * @description + * Returns the current *active* R executable. + * This may differ depending on the current active workspace folder. + * @type {RExecutable} + * @memberof RExecutableService + */ + public get activeExecutable(): RExecutableType | undefined { + const currWorkspacePath = getCurrentWorkspaceFolder()?.uri?.fsPath; + if (currWorkspacePath) { + return this.workspaceExecutables.get(currWorkspacePath); + } + + const currentDocument = vscode?.window?.activeTextEditor?.document?.uri?.fsPath; + if (currentDocument) { + return this.workspaceExecutables.get(currentDocument); + } + + return undefined; + } + + /** + * @description + * Set the R executable associated with a given workspace folder. + * @param {string} folder + * @param {RExecutable} executable + * @memberof RExecutableService + */ + public setWorkspaceExecutable(folder: string, executable: RExecutableType | undefined): void { + if (this.workspaceExecutables.get(folder) !== executable) { + if (!executable) { + this.executableStorage.setExecutablePath(folder, undefined); + this.workspaceEmitter.fire({ workingFolder: undefined, executable: executable }); + } else { + const workspaceFolderUri = vscode.Uri.file(folder); + this.workspaceEmitter.fire({ workingFolder: vscode.workspace.getWorkspaceFolder(workspaceFolderUri), executable: executable }); + this.executableStorage.setExecutablePath(folder, executable.rBin); + } + } + this.workspaceExecutables.set(folder, executable); + this.executableEmitter.fire(executable); + } + + /** + * @description + * Get the R executable associated with a given workspace folder. + * @param {string} folder + * @returns {*} {RExecutable} + * @memberof RExecutableService + */ + public getWorkspaceExecutable(folder: string): RExecutableType | undefined { + return this.workspaceExecutables.get(folder); + } + + /** + * @description + * An event that is fired whenever the active executable changes. + * This can occur, for instance, when changing focus between multi-root workspaces. + * @readonly + * @type {vscode.Event} + * @memberof RExecutableService + */ + public get onDidChangeActiveExecutable(): vscode.Event { + return this.executableEmitter.event; + } + + /** + * @description + * Event that is triggered when the executable associated with a workspace is changed. + * @readonly + * @type {vscode.Event} + * @memberof RExecutableService + */ + public get onDidChangeWorkspaceExecutable(): vscode.Event { + return this.workspaceEmitter.event; + } + + /** + * @description + * @memberof RExecutableService + */ + public dispose(): void { + this.executableEmitter.dispose(); + this.workspaceEmitter.dispose(); + } + + private resolveRExecutableForPath(workspacePath: string, confPath: string | undefined) { + if (!this.workspaceExecutables.has(workspacePath)) { + // is there a local virtual env? + // TODO + + // is there a renv-recommended version? + const renvVersion = getRenvVersion(workspacePath); + if (renvVersion) { + const compatibleExecutables = this.executableRegistry.getExecutablesWithVersion(renvVersion); + if (compatibleExecutables) { + const exec = compatibleExecutables.sort((a, b) => { + if (a.rBin === confPath) { + return -1; + } + if (b.rBin === confPath) { + return 1; + } + return 0; + }); + this.workspaceExecutables.set(workspacePath, exec[0]); + return; + } + } + + // fallback to a configured path if it exists + if (confPath && validateRExecutablePath(confPath)) { + console.log(`[RExecutableService] Executable set to configuration path: ${confPath}`); + const exec = this.executableFactory.create(confPath); + this.workspaceExecutables.set(workspacePath, exec); + } + } + } + + private selectViableExecutables(): void { + // from storage, recreate associations between workspace paths and executable paths + for (const [dirPath, execPath] of this.executableStorage.executablePaths) { + if (validateRExecutablePath(execPath)) { + this.workspaceExecutables.set(dirPath, this.executableFactory.create(execPath)); + } + } + + const configPath = getConfigPathWithSubstitution(); + if (vscode.workspace.workspaceFolders) { + for (const workspace of vscode.workspace.workspaceFolders) { + this.resolveRExecutableForPath(workspace.uri.path, configPath); + } + } else { + this.resolveRExecutableForPath(homeExtDir(), configPath); + } + } +} diff --git a/src/executables/service/locator/index.ts b/src/executables/service/locator/index.ts new file mode 100644 index 000000000..446f9c651 --- /dev/null +++ b/src/executables/service/locator/index.ts @@ -0,0 +1,31 @@ +'use strict'; + +import { UnixExecLocator } from './unix'; +import { WindowsExecLocator } from './windows'; +import { AbstractLocatorService } from './shared'; + +/** + * Static class factory for the creation of executable locators + */ +export class LocatorServiceFactory { + /** + * Returns a new AbstractLocatorService, dependent on + * the process' platform + * @returns instance of AbstractLocatorService + */ + static getLocator(): AbstractLocatorService { + if (process.platform === 'win32') { + return new WindowsExecLocator(); + } else { + return new UnixExecLocator(); + } + } + + private constructor() { + // + } +} + + +// TODO +export type TAbstractLocatorService = AbstractLocatorService; \ No newline at end of file diff --git a/src/executables/service/locator/shared.ts b/src/executables/service/locator/shared.ts new file mode 100644 index 000000000..4421e51a0 --- /dev/null +++ b/src/executables/service/locator/shared.ts @@ -0,0 +1,58 @@ +'use strict'; + +import * as fs from 'fs-extra'; +import * as vscode from 'vscode'; + +/** + * For a given array of paths, return only unique paths + * (including symlinks), favouring shorter paths + * @param paths + */ +export function getUniquePaths(paths: string[]): string[] { + function realpath(path: string): string { + if (fs.lstatSync(path).isSymbolicLink()) { + return fs.realpathSync(path); + } + return path; + } + function existsInSet(set: Set, path: string): string { + const arr: string[] = []; + set.forEach((v) => { + if (realpath(path) === realpath(v)) { + arr.push(v); + } + }); + return arr?.[0]; + } + + const out: Set = new Set(); + for (const path of paths) { + const truepath = realpath(path); + const storedpath = existsInSet(out, path); + if (storedpath) { + if (storedpath.length > truepath.length) { + out.delete(storedpath); + out.add(truepath); + } + } else { + const shortestPath = truepath.length <= path.length ? truepath : path; + out.add(shortestPath); + } + } + return [...out.values()]; +} + +export abstract class AbstractLocatorService { + protected _executablePaths!: string[]; + protected emitter!: vscode.EventEmitter; + public abstract refreshPaths(): Promise; + public get hasPaths(): boolean { + return this._executablePaths.length > 0; + } + public get executablePaths(): string[] { + return this._executablePaths; + } + public get onDidRefreshPaths(): vscode.Event { + return this.emitter.event; + } +} \ No newline at end of file diff --git a/src/executables/service/locator/unix.ts b/src/executables/service/locator/unix.ts new file mode 100644 index 000000000..4d72ac070 --- /dev/null +++ b/src/executables/service/locator/unix.ts @@ -0,0 +1,93 @@ +'use strict'; + +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import * as path from 'path'; + +import { AbstractLocatorService, getUniquePaths } from './shared'; + +export class UnixExecLocator extends AbstractLocatorService { + constructor() { + super(); + this.emitter = new vscode.EventEmitter(); + this._executablePaths = []; + } + // eslint-disable-next-line @typescript-eslint/require-await + public async refreshPaths(): Promise { + this._executablePaths = getUniquePaths(Array.from( + new Set([ + ...this.getPathFromDirs(), + ...this.getPathFromEnv(), + ...this.getPathFromConda() + ]) + )); + this.emitter.fire(this._executablePaths); + } + + private getPathFromDirs(): string[] { + const execPaths: string[] = []; + const potentialPaths: string[] = [ + '/usr/lib64/R/bin/R', + '/usr/lib/R/bin/R', + '/usr/local/lib64/R/bin/R', + '/usr/local/lib/R/bin/R', + '/opt/local/lib64/R/bin/R', + '/opt/local/lib/R/bin/R' + ]; + + for (const bin of potentialPaths) { + if (fs.existsSync(bin)) { + execPaths.push(bin); + } + } + return execPaths; + } + + private getPathFromConda(): string[] { + const execPaths: string[] = []; + const condaDirs = [ + `${os.homedir()}/.conda/environments.txt` + ]; + for (const condaEnv of condaDirs) { + if (fs.existsSync(condaEnv)) { + const lines = fs.readFileSync(condaEnv)?.toString(); + if (lines) { + for (const line of lines.split('\n')) { + if (line) { + const rDirs = [ + `${line}/lib64/R/bin/R`, + `${line}/lib/R/bin/R` + ]; + for (const dir of rDirs) { + if (fs.existsSync(dir)) { + execPaths.push(dir); + } + } + } + } + } + } + } + return execPaths; + } + + /** + * @returns Array of paths to R executables found in PATH variable + */ + private getPathFromEnv(): string[] { + const execPaths: string[] = []; + const osPaths: string[] | string | undefined = process?.env?.PATH?.split(';'); + + if (osPaths) { + for (const osPath of osPaths) { + const rPath: string = path.join(osPath, 'R'); + if (fs.existsSync(rPath)) { + execPaths.push(rPath); + } + } + } + + return execPaths; + } +} \ No newline at end of file diff --git a/src/executables/service/locator/windows.ts b/src/executables/service/locator/windows.ts new file mode 100644 index 000000000..d88a890e3 --- /dev/null +++ b/src/executables/service/locator/windows.ts @@ -0,0 +1,147 @@ +'use strict'; + +import * as fs from 'fs-extra'; +import * as vscode from 'vscode'; +import * as os from 'os'; +import * as path from 'path'; +import * as winreg from 'winreg'; +import { getUniquePaths, AbstractLocatorService } from './shared'; + + +const WindowsKnownPaths: string[] = []; + +if (process.env.ProgramFiles) { + WindowsKnownPaths.push( + path.join(process?.env?.ProgramFiles, 'R'), + path.join(process?.env?.ProgramFiles, 'Microsoft', 'R Open') + ); +} + +if (process.env['ProgramFiles(x86)']) { + WindowsKnownPaths.push( + path.join(process?.env?.['ProgramFiles(x86)'], 'R'), + path.join(process?.env?.['ProgramFiles(x86)'], 'Microsoft', 'R Open') + ); +} + + +export class WindowsExecLocator extends AbstractLocatorService { + constructor() { + super(); + this.emitter = new vscode.EventEmitter(); + this._executablePaths = []; + } + public async refreshPaths(): Promise { + this._executablePaths = getUniquePaths(Array.from( + new Set([ + ...this.getPathFromDirs(), + ...this.getPathFromEnv(), + ...await this.getPathFromRegistry(), + ...this.getPathFromConda() + ]) + )); + this.emitter.fire(this._executablePaths); + } + + private async getPathFromRegistry(): Promise { + const execPaths: string[] = []; + const potentialRegs = [ + new winreg({ + hive: winreg.HKLM, + key: '\\SOFTWARE\\R-core\\R', + }), + new winreg({ + hive: winreg.HKLM, + key: '\\SOFTWARE\\R-core\\R64', + }) + ]; + + for (const reg of potentialRegs) { + const res: unknown = await new Promise((resolve, reject) => { + reg.get('InstallPath', (err, result) => err === null ? resolve(result) : reject(err)); + }); + + if (res) { + const resolvedPath = (res as winreg.RegistryItem).value; + const i386 = `${resolvedPath}\\i386\\`; + const x64 = `${resolvedPath}\\x64\\`; + + if (fs.existsSync(i386)) { + execPaths.push(i386); + } + + if (fs.existsSync(x64)) { + execPaths.push(x64); + } + } + } + + return execPaths; + } + + private getPathFromDirs(): string[] { + const execPaths: string[] = []; + for (const rPath of WindowsKnownPaths) { + if (fs.existsSync(rPath)) { + const dirs = fs.readdirSync(rPath); + for (const dir of dirs) { + const i386 = `${rPath}\\${dir}\\bin\\i386\\R.exe`; + const x64 = `${rPath}\\${dir}\\bin\\x64\\R.exe`; + + if (fs.existsSync(i386)) { + execPaths.push(i386); + } + + if (fs.existsSync(x64)) { + execPaths.push(x64); + } + } + } + } + return execPaths; + } + + private getPathFromEnv(): string[] { + const execPaths: string[] = []; + const osPaths: string[] | string | undefined = process?.env?.PATH?.split(';'); + + if (osPaths) { + for (const osPath of osPaths) { + const rPath: string = path.join(osPath, '\\R.exe'); + if (fs.existsSync(rPath)) { + execPaths.push(rPath); + } + } + } + + return execPaths; + } + + private getPathFromConda() { + const execPaths: string[] = []; + const condaDirs = [ + `${os.homedir()}\\.conda\\environments.txt` + ]; + for (const rPath of condaDirs) { + if (fs.existsSync(rPath)) { + const lines = fs.readFileSync(rPath)?.toString(); + if (lines) { + for (const line of lines.split('\r\n')) { + if (line) { + const potentialDirs = [ + `${line}\\lib64\\R\\bin\\R.exe`, + `${line}\\lib\\R\\bin\\R.exe` + ]; + for (const dir of potentialDirs) { + if (fs.existsSync(dir)) { + execPaths.push(dir); + } + } + } + } + } + } + } + return execPaths; + } +} \ No newline at end of file diff --git a/src/executables/service/pathStorage.ts b/src/executables/service/pathStorage.ts new file mode 100644 index 000000000..a49180d1a --- /dev/null +++ b/src/executables/service/pathStorage.ts @@ -0,0 +1,74 @@ +'use strict'; + +import { extensionContext } from '../../extension'; +import { getCurrentWorkspaceFolder } from '../../util'; + +/** + * Stores and retrieves R executable paths for + * different workspace folders in vscode + */ +export class RExecutablePathStorage { + private store: Map; + + constructor() { + this.store = this.getExecutableStore(); + } + + public get executablePaths(): Map { + return this.store; + } + + /** + * Sets the executable path for the given working directory. + * If binPath is undefined, it removes the executable path + * for the given working directory. + * @param workingDir + * @param binPath + */ + public setExecutablePath(workingDir: string, binPath: string | undefined): void { + if (binPath) { + this.store.set(workingDir, binPath); + } else { + this.store.delete(workingDir); + } + void this.saveStorage(); + } + + public getActiveExecutablePath(): string | undefined { + const currentWorkspace = getCurrentWorkspaceFolder()?.uri?.fsPath; + if (currentWorkspace) { + return this.store.get(currentWorkspace); + } else { + return undefined; + } + } + + public getExecutablePath(workingDir: string): string | undefined { + return this.store.get(workingDir); + } + + private getExecutableStore(): Map { + return this.stringToMap(extensionContext.globalState.get('rExecMap', '')); + } + + private async saveStorage(): Promise { + const out = this.mapToString(this.store); + await extensionContext.globalState.update('rExecMap', out); + } + + private mapToString(map: Map): string { + try { + return JSON.stringify([...map]); + } catch (error) { + return ''; + } + } + + private stringToMap(str: string): Map { + try { + return new Map(JSON.parse(str) as Map); + } catch (error) { + return new Map(); + } + } +} \ No newline at end of file diff --git a/src/executables/service/registry.ts b/src/executables/service/registry.ts new file mode 100644 index 000000000..76c5c450b --- /dev/null +++ b/src/executables/service/registry.ts @@ -0,0 +1,33 @@ +'use strict'; + +import { RExecutableType } from './types'; + +// necessary to have an executable registry +// so that we don't spam the (re)creation of executables +export class RExecutableRegistry { + private readonly _executables: Set; + + constructor() { + this._executables = new Set(); + } + + public get executables(): Set { + return this._executables; + } + + public addExecutable(executable: RExecutableType): Set { + return this._executables.add(executable); + } + + public deleteExecutable(executable: RExecutableType): boolean { + return this._executables.delete(executable); + } + + public hasExecutable(executable: RExecutableType): boolean { + return this._executables.has(executable); + } + + public getExecutablesWithVersion(version: string): RExecutableType[] { + return [...this._executables.values()].filter((v) => v.rVersion === version); + } +} \ No newline at end of file diff --git a/src/executables/service/types.ts b/src/executables/service/types.ts new file mode 100644 index 000000000..bb56e3b0a --- /dev/null +++ b/src/executables/service/types.ts @@ -0,0 +1,22 @@ +'use strict'; + +import * as vscode from 'vscode'; +import { AbstractRExecutable, AbstractVirtualRExecutable } from './class'; + +export type RExecutableType = AbstractRExecutable; +export type VirtualRExecutableType = AbstractVirtualRExecutable; + +export interface IExecutableDetails { + version: string | undefined, + arch: string | undefined +} + +/** + * @description + * @export + * @interface WorkspaceExecutableEvent + */ +export interface WorkspaceExecutableEvent { + workingFolder: vscode.WorkspaceFolder | undefined, + executable: RExecutableType | undefined +} \ No newline at end of file diff --git a/src/executables/ui/index.ts b/src/executables/ui/index.ts new file mode 100644 index 000000000..b445dade0 --- /dev/null +++ b/src/executables/ui/index.ts @@ -0,0 +1,2 @@ +export { ExecutableQuickPick } from './quickpick'; +export { ExecutableStatusItem } from './status'; \ No newline at end of file diff --git a/src/executables/ui/quickpick.ts b/src/executables/ui/quickpick.ts new file mode 100644 index 000000000..bb3b2b992 --- /dev/null +++ b/src/executables/ui/quickpick.ts @@ -0,0 +1,319 @@ +'use strict'; + +import path = require('path'); +import * as vscode from 'vscode'; + +import { getCurrentWorkspaceFolder, isMultiRoot } from '../../util'; +import { RExecutableType } from '../service'; +import { RExecutableService } from '../service'; +import { isVirtual } from '../virtual'; +import { getConfigPathWithSubstitution, validateRExecutablePath } from '../util'; +import { getRenvVersion } from '../virtual/renv'; +import { extensionContext } from '../../extension'; + +enum ExecutableNotifications { + badFolder = 'Supplied R executable path is not a valid R path.', + badConfig = 'Configured path is not a valid R executable path.', + badInstallation = 'Supplied R executable cannot be launched on this operating system.' +} + +enum PathQuickPickMenu { + search = '$(add) Enter R executable path...', + configuration = '$(settings-gear) Configuration path', + badPath = 'Invalid R path' +} + +class ExecutableQuickPickItem implements vscode.QuickPickItem { + public recommended: boolean; + public category: string; + public label: string; + public description: string; + public detail?: string; + public picked?: boolean; + public alwaysShow?: boolean; + public active!: boolean; + private _executable: RExecutableType; + + constructor(executable: RExecutableType, service: RExecutableService, workspaceFolder: vscode.WorkspaceFolder, renvVersion?: string) { + this._executable = executable; + this.description = executable.rBin; + this.recommended = recommendPath(executable, workspaceFolder, renvVersion); + + if (isVirtual(executable)) { + this.category = 'Virtual'; + } else { + this.category = 'Global'; + } + + if (this.recommended) { + this.label = `$(star-full) ${executable.tooltip}`; + } else { + this.label = executable.tooltip; + } + + if (service.getWorkspaceExecutable(workspaceFolder?.uri?.fsPath)?.rBin === executable.rBin) { + this.label = `$(indent) ${this.label}`; + this.active = true; + } + + } + + public get executable(): RExecutableType { + return this._executable; + } + +} + +export class ExecutableQuickPick { + private readonly service: RExecutableService; + private quickpick!: vscode.QuickPick; + private currentFolder: vscode.WorkspaceFolder | undefined; + + public constructor(service: RExecutableService) { + this.service = service; + this.currentFolder = getCurrentWorkspaceFolder(); + extensionContext.subscriptions.push(this.quickpick); + } + + private setItems(): void { + const qpItems: vscode.QuickPickItem[] = []; + + // TODO, we repeat this a few times + const configPath = getConfigPathWithSubstitution(); + + const sortExecutables = (a: RExecutableType, b: RExecutableType) => { + return -a.rVersion.localeCompare(b.rVersion, undefined, { numeric: true, sensitivity: 'base' }); + }; + qpItems.push( + { + label: PathQuickPickMenu.search, + alwaysShow: true, + picked: false + } + ); + if (configPath) { + qpItems.push({ + label: PathQuickPickMenu.configuration, + alwaysShow: true, + description: configPath, + detail: validateRExecutablePath(configPath) ? '' : PathQuickPickMenu.badPath, + picked: false + }); + } + + const renvVersion = this.currentFolder?.uri?.fsPath ? getRenvVersion(this.currentFolder?.uri?.fsPath) : undefined; + const recommendedItems: vscode.QuickPickItem[] = [ + { + label: 'Recommended', + kind: vscode.QuickPickItemKind.Separator + } + ]; + const virtualItems: vscode.QuickPickItem[] = [ + { + label: 'Virtual', + kind: vscode.QuickPickItemKind.Separator + } + ]; + const globalItems: vscode.QuickPickItem[] = [ + { + label: 'Global', + kind: vscode.QuickPickItemKind.Separator + } + ]; + + [...this.service.executables].sort(sortExecutables).forEach((executable) => { + if (this.currentFolder) { + const quickPickItem = new ExecutableQuickPickItem( + executable, + this.service, + this.currentFolder, + renvVersion + ); + if (quickPickItem.recommended) { + recommendedItems.push(quickPickItem); + } else { + switch (quickPickItem.category) { + case 'Virtual': { + virtualItems.push(quickPickItem); + break; + } + case 'Global': { + globalItems.push(quickPickItem); + break; + } + } + } + } + }); + + + this.quickpick.items = [...qpItems, ...recommendedItems, ...virtualItems, ...globalItems]; + for (const quickPickItem of this.quickpick.items) { + if ((quickPickItem as ExecutableQuickPickItem)?.active) { + this.quickpick.activeItems = [quickPickItem]; + } + } + } + + /** + * @description + * Basic display of the quickpick is: + * - Manual executable selection + * - Configuration path (may be hidden) + * - Recommended paths (may be hidden) + * - Virtual paths + * - Global paths + * @returns {*} {Promise} + * @memberof ExecutableQuickPick + */ + public async showQuickPick(): Promise { + const setupQuickpickOpts = () => { + this.quickpick = vscode.window.createQuickPick(); + this.quickpick.title = 'Select R executable path'; + this.quickpick.canSelectMany = false; + this.quickpick.ignoreFocusOut = true; + this.quickpick.matchOnDescription = true; + this.quickpick.buttons = [ + { iconPath: new vscode.ThemeIcon('clear-all'), tooltip: 'Clear stored path' }, + { iconPath: new vscode.ThemeIcon('refresh'), tooltip: 'Refresh paths' } + ]; + }; + + const setupQuickpickListeners = (resolver: () => void) => { + this.quickpick.onDidTriggerButton(async (item: vscode.QuickInputButton) => { + if (item.tooltip === 'Refresh paths') { + await this.service.executablePathLocator.refreshPaths(); + this.setItems(); + this.quickpick.show(); + } else { + if (this.currentFolder) { + this.service.setWorkspaceExecutable(this.currentFolder?.uri?.fsPath, undefined); + } + this.quickpick.hide(); + } + }); + this.quickpick.onDidChangeSelection((items: readonly vscode.QuickPickItem[] | ExecutableQuickPickItem[]) => { + const qpItem = items[0]; + if (qpItem.label) { + switch (qpItem.label) { + case PathQuickPickMenu.search: { + const opts: vscode.OpenDialogOptions = { + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + title: ' R executable file' + }; + void vscode.window.showOpenDialog(opts).then((epath: vscode.Uri[] | undefined) => { + if (epath && this.currentFolder) { + const execPath = path.normalize(epath?.[0].fsPath); + if (execPath && validateRExecutablePath(execPath)) { + const rExec = this.service.executableFactory.create(execPath); + this.service.setWorkspaceExecutable(this.currentFolder?.uri?.fsPath, rExec); + } else { + void vscode.window.showErrorMessage(ExecutableNotifications.badFolder); + this.service.setWorkspaceExecutable(this.currentFolder?.uri?.fsPath, undefined); + } + } + }); + break; + } + case PathQuickPickMenu.configuration: { + const configPath = getConfigPathWithSubstitution(); + if (this.currentFolder) { + if (configPath && validateRExecutablePath(configPath)) { + const rExec = this.service.executableFactory.create(configPath); + this.service.setWorkspaceExecutable(this.currentFolder?.uri?.fsPath, rExec); + } else { + void vscode.window.showErrorMessage(ExecutableNotifications.badConfig); + this.service.setWorkspaceExecutable(this.currentFolder?.uri?.fsPath, undefined); + } + } + break; + } + default: { + const executable = (qpItem as ExecutableQuickPickItem).executable; + if (this.currentFolder) { + if (executable?.rVersion) { + this.service.setWorkspaceExecutable(this.currentFolder?.uri?.fsPath, executable); + } else { + void vscode.window.showErrorMessage(ExecutableNotifications.badInstallation); + this.service.setWorkspaceExecutable(this.currentFolder?.uri?.fsPath, undefined); + } + } + break; + } + } + } + this.quickpick.hide(); + resolver(); + }); + }; + + return await new Promise((res) => { + setupQuickpickOpts(); + setupQuickpickListeners(res); + void showWorkspaceFolderQP().then((folder: vscode.WorkspaceFolder | undefined) => { + this.currentFolder = folder; + let currentExec; + if (this.currentFolder) { + currentExec = this.service.getWorkspaceExecutable(this.currentFolder?.uri?.fsPath); + } + if (currentExec) { + this.quickpick.placeholder = `Current path: ${currentExec.rBin}`; + } else { + this.quickpick.placeholder = ''; + } + this.setItems(); + this.quickpick.show(); + }); + }); + } +} + +async function showWorkspaceFolderQP(): Promise { + const opts: vscode.WorkspaceFolderPickOptions = { + ignoreFocusOut: true, + placeHolder: 'Select a workspace folder to define an R path for' + }; + const currentDocument = vscode?.window?.activeTextEditor?.document?.uri; + if (isMultiRoot()) { + const workspaceFolder = await vscode.window.showWorkspaceFolderPick(opts); + if (workspaceFolder) { + return workspaceFolder; + } else if (currentDocument) { + return { + index: 0, + uri: currentDocument, + name: 'untitled' + }; + } + } + + if (currentDocument) { + const folder = vscode.workspace.getWorkspaceFolder(currentDocument); + if (folder) { + return folder; + } else { + return { + index: 0, + uri: currentDocument, + name: 'untitled' + }; + } + } + + return undefined; +} + +function recommendPath(executable: RExecutableType, workspaceFolder: vscode.WorkspaceFolder, renvVersion?: string): boolean { + if (renvVersion) { + const compatibleBin = renvVersion === executable.rVersion; + if (compatibleBin) { + return true; + } + + } + const uri = vscode.Uri.file(executable.rBin); + const possibleWorkspace = vscode.workspace.getWorkspaceFolder(uri); + return !!possibleWorkspace && possibleWorkspace === workspaceFolder; +} \ No newline at end of file diff --git a/src/executables/ui/status.ts b/src/executables/ui/status.ts new file mode 100644 index 000000000..41636766c --- /dev/null +++ b/src/executables/ui/status.ts @@ -0,0 +1,72 @@ +'use strict'; + +import * as vscode from 'vscode'; + +import { isVirtual } from '../virtual'; +import { RExecutableService } from '../service'; + +enum BinText { + name = 'R Language Indicator', + missing = '$(warning) Select R executable' +} + +const rFileTypes = [ + 'r', + 'rmd', + 'rProfile', + 'rd', + 'rproj', + 'rnw' +]; + +export class ExecutableStatusItem implements vscode.Disposable { + private readonly service: RExecutableService; + private readonly languageStatusItem!: vscode.LanguageStatusItem; + + public constructor(service: RExecutableService) { + this.service = service; + this.languageStatusItem = vscode.languages.createLanguageStatusItem('R Executable Selector', rFileTypes); + this.languageStatusItem.name = 'R Language Service'; + this.languageStatusItem.command = { + 'title': 'Select R executable', + 'command': 'r.setExecutable' + }; + this.refresh(); + } + + public get text(): string { + return this.languageStatusItem.text; + } + + public get busy(): boolean { + return this.languageStatusItem.busy; + } + + public get severity(): vscode.LanguageStatusSeverity { + return this.languageStatusItem.severity; + } + + public refresh(): void { + const execState = this.service?.activeExecutable; + if (execState) { + this.languageStatusItem.severity = vscode.LanguageStatusSeverity.Information; + this.languageStatusItem.detail = execState.rBin; + if (isVirtual(execState)) { + const versionString = execState.rVersion ? ` (${execState.rVersion})` : ''; + const name = execState.name ? execState.name : ''; + this.languageStatusItem.text = `${name}${versionString}`; + } else { + this.languageStatusItem.text = execState.rVersion; + } + } else { + this.languageStatusItem.severity = vscode.LanguageStatusSeverity.Warning; + this.languageStatusItem.text = BinText.missing; + this.languageStatusItem.detail = ''; + } + } + + public dispose(): void { + this.languageStatusItem.dispose(); + } + +} \ No newline at end of file diff --git a/src/executables/util.ts b/src/executables/util.ts new file mode 100644 index 000000000..f574c3540 --- /dev/null +++ b/src/executables/util.ts @@ -0,0 +1,53 @@ +'use strict'; + +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { spawnSync } from 'child_process'; +import { config, getRPathConfigEntry, normaliseRPathString, substituteVariables } from '../util'; + +export function getConfigPathWithSubstitution(): string | undefined { + let rpath = config().get(getRPathConfigEntry()); + rpath &&= substituteVariables(rpath); + rpath ||= undefined; + return rpath; +} + +/** + * Parses R version and architecture from a given R executable path. + * + * @param rPath string representing the path to an R executable. + * @returns object with R version and architecture as strings + */ +export function getRDetailsFromPath(rPath: string): { version: string, arch: string } { + try { + const path = normaliseRPathString(rPath); + // TODO is not virtual aware? + const child = spawnSync(path, [`--version`]).output.join('\n'); + const versionRegex = /(?<=R\sversion\s)[0-9.]*/g; + const archRegex = /[0-9]*-bit/g; + const out = { + version: child.match(versionRegex)?.[0] ?? '', + arch: child.match(archRegex)?.[0] ?? '' + }; + return out; + } catch (error) { + return { version: '', arch: '' }; + } +} + +/** + * Is the folder of a given executable a valid R installation? + * + * A path is valid if the folder contains the R executable and an Rcmd file. + * @param execPath + * @returns boolean + */ +export function validateRExecutablePath(execPath: string): boolean { + try { + const basename = process.platform === 'win32' ? 'R.exe' : 'R'; + fs.accessSync(execPath, fs.constants.X_OK && fs.constants.R_OK); + return (path.basename(execPath) === basename); + } catch (error) { + return false; + } +} diff --git a/src/executables/virtual/conda.ts b/src/executables/virtual/conda.ts new file mode 100644 index 000000000..86ab0a111 --- /dev/null +++ b/src/executables/virtual/conda.ts @@ -0,0 +1,51 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { IExecutableDetails, RExecutableType } from '../service'; +import { CondaVirtualRExecutable } from '../service/class'; + +export function isCondaInstallation(executablePath: string): boolean { + return fs.existsSync(condaMetaDirPath(executablePath)); +} + +export function isCondaExecutable(executable: RExecutableType) { + return executable instanceof CondaVirtualRExecutable; +} + +export function getRDetailsFromCondaMetaHistory(executablePath: string): IExecutableDetails { + try { + const reg = new RegExp(/([0-9]{2})::r-base-([0-9.]*)/g); + const historyContent = fs.readFileSync(condaHistoryPath(executablePath))?.toString(); + const res = reg.exec(historyContent); + return { + arch: res?.[1] ? `${res[1]}-bit` : '', + version: res?.[2] ? res[2] : '' + }; + } catch (error) { + return { + arch: '', + version: '' + }; + } +} + +export function condaName(executablePath: string): string { + return path.basename(condaPrefixPath(executablePath)); +} + +function condaPrefixPath(executablePath: string): string { + return path.dirname(condaMetaDirPath(executablePath)); +} + +function condaMetaDirPath(executablePath: string): string { + let envDir: string = executablePath; + for (let index = 0; index < 4; index++) { + envDir = path.dirname(envDir); + } + return path.join(envDir, 'conda-meta'); +} + +function condaHistoryPath(executablePath: string): string { + return path.join(condaMetaDirPath(executablePath), 'history'); +} + + diff --git a/src/executables/virtual/index.ts b/src/executables/virtual/index.ts new file mode 100644 index 000000000..207a7d303 --- /dev/null +++ b/src/executables/virtual/index.ts @@ -0,0 +1,63 @@ +import { isCondaExecutable } from './conda'; +import { CondaVirtualRExecutable } from '../service/class'; +import { isMambaExecutable } from './mamba'; +import { MambaVirtualRExecutable } from '../service/class'; +import { rExecutableManager } from '../../extension'; +import { getRterm } from '../../util'; +import { AbstractRExecutable, AbstractVirtualRExecutable , RExecutableType } from '../service'; + +export * from './conda'; +export * from './mamba'; +export * from './renv'; + +export function isVirtual(executable: AbstractRExecutable): executable is AbstractVirtualRExecutable { + return executable instanceof AbstractVirtualRExecutable; +} + +export interface IProcessArgs { + cmd: string; + args?: string[]; +} + +function virtualAwareArgs( + executable: CondaVirtualRExecutable | MambaVirtualRExecutable, + interactive: boolean, + shellArgs: string[] | ReadonlyArray): IProcessArgs { + const rpath = interactive ? getRterm() : executable.rBin; + const cmd: 'conda' | 'mamba' = isCondaExecutable(executable) ? 'conda' : + isMambaExecutable(executable) ? 'mamba' : + (() => { throw 'Unknown virtual executable'; })(); + + if (!rpath) { + throw 'Bad R executable path'; + } + + const args = [ + 'run', + '-n', + executable.name, + '--no-capture-output', + rpath, + ...(shellArgs ? shellArgs : []) + ]; + + return { + cmd: cmd, + args: args + }; +} + +export function setupVirtualAwareProcessArguments(executable: string, interactive: boolean, args?: ReadonlyArray): IProcessArgs; +export function setupVirtualAwareProcessArguments(executable: RExecutableType, interactive: boolean, args?: ReadonlyArray): IProcessArgs; +export function setupVirtualAwareProcessArguments(executable: RExecutableType | string, interactive: boolean, args?: ReadonlyArray): IProcessArgs { + const rexecutable = typeof executable === 'string' ? rExecutableManager?.getExecutableFromPath(executable) : executable; + if (!rexecutable) { + throw 'Bad R executable path'; + } + if (isVirtual(rexecutable)) { + const virtualArgs = virtualAwareArgs(rexecutable, interactive, args ?? []); + return { cmd: virtualArgs.cmd, args: virtualArgs.args }; + } else { + return { cmd: rexecutable.rBin, args: args?.concat() ?? [] }; + } +} diff --git a/src/executables/virtual/mamba.ts b/src/executables/virtual/mamba.ts new file mode 100644 index 000000000..47059cd2b --- /dev/null +++ b/src/executables/virtual/mamba.ts @@ -0,0 +1,12 @@ +import { RExecutableType } from '../service'; +import { MambaVirtualRExecutable } from '../service/class'; + +export function isMambaExecutable(executable: RExecutableType) { + return executable instanceof MambaVirtualRExecutable; +} + +// TODO +export function isMambaInstallation(executablePath: string): boolean { + return executablePath === 'linter appeasement'; +} + diff --git a/src/executables/virtual/renv.ts b/src/executables/virtual/renv.ts new file mode 100644 index 000000000..7b201551e --- /dev/null +++ b/src/executables/virtual/renv.ts @@ -0,0 +1,57 @@ +'use strict'; + +import * as path from 'path'; +import * as fs from 'fs-extra'; + +export function getRenvVersion(workspacePath: string): string | undefined { + if (isRenvWorkspace(workspacePath)) { + try { + const lockPath = path.join(workspacePath, 'renv.lock'); + if (!fs.existsSync(lockPath)) { + return ''; + } + const lockContent = fs.readJSONSync(lockPath) as IRenvJSONLock; + return lockContent?.R?.Version ?? ''; + } catch (error) { + return ''; + } + } else { + return undefined; + } +} + +export function isRenvWorkspace(workspacePath: string): boolean { + try { + const renvPath = path.join(workspacePath, 'renv'); + return fs.existsSync(renvPath); + } catch (error) { + return false; + } +} + +type LockPythonType = 'virtualenv' | 'conda' | 'system' + +interface IRenvJSONLock { + R: { + Version: string, + Repositories: { + 'Name': string, + 'URL': string + }[] + }, + Packages: { + [key: string]: { + Package: string, + Version: string, + Source: string, + Repository: string, + Hash?: string + } + + }, + Python?: { + Version: string, + Type: LockPythonType + Name?: string + } +} diff --git a/src/extension.ts b/src/extension.ts index 67a07ea07..f275aa064 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,3 @@ - 'use strict'; // interfaces, functions, etc. provided by vscode @@ -23,6 +22,7 @@ import * as completions from './completions'; import * as rShare from './liveShare'; import * as httpgdViewer from './plotViewer'; import * as languageService from './languageService'; +import * as rExec from './executables'; import { RTaskProvider } from './tasks'; @@ -37,6 +37,7 @@ export let globalHttpgdManager: httpgdViewer.HttpgdManager | undefined = undefin export let rmdPreviewManager: rmarkdown.RMarkdownPreviewManager | undefined = undefined; export let rmdKnitManager: rmarkdown.RMarkdownKnitManager | undefined = undefined; export let sessionStatusBarItem: vscode.StatusBarItem | undefined = undefined; +export let rExecutableManager: rExec.RExecutableManager | undefined = undefined; // Called (once) when the extension is activated export async function activate(context: vscode.ExtensionContext): Promise { @@ -52,6 +53,7 @@ export async function activate(context: vscode.ExtensionContext): Promise('sessionWatcher') ?? false; @@ -63,6 +65,7 @@ export async function activate(context: vscode.ExtensionContext): Promise rExecutableManager?.executableQuickPick.showQuickPick(), // run code from editor in terminal 'r.nrow': () => rTerminal.runSelectionOrWord(['nrow']), @@ -158,17 +161,21 @@ export async function activate(context: vscode.ExtensionContext): Promise('lsp.enabled')) { - const lsp = vscode.extensions.getExtension('reditorsupport.r-lsp'); - if (lsp) { - void vscode.window.showInformationMessage('The R language server extension has been integrated into vscode-R. You need to disable or uninstall REditorSupport.r-lsp and reload window to use the new version.'); - void vscode.commands.executeCommand('workbench.extensions.search', '@installed r-lsp'); - } else { - context.subscriptions.push(new languageService.LanguageService()); - } + // TODO + globalHttpgdManager = httpgdViewer.initializeHttpgd(); + + if (rExecutableManager.activeExecutable) { + activateServices(context, rExtension); } + // TODO, this is a stopgap + // doesn't really work for for multi-root purposes + rExecutableManager?.onDidChangeActiveExecutable((exec) => { + if (exec) { + activateServices(context, rExtension); + } + }); + // register on-enter rule for roxygen comments const wordPattern = /(-?\d*\.\d\w*)|([^`~!@$^&*()=+[{\]}\\|;:'",<>/\s]+)/g; vscode.languages.setLanguageConfiguration('r', { @@ -191,20 +198,14 @@ export async function activate(context: vscode.ExtensionContext): Promise('lsp.enabled')) { + const lsp = vscode.extensions.getExtension('reditorsupport.r-lsp'); + if (lsp) { + void vscode.window.showInformationMessage('The R language server extension has been integrated into vscode-R. You need to disable or uninstall REditorSupport.r-lsp and reload window to use the new version.'); + void vscode.commands.executeCommand('workbench.extensions.search', '@installed r-lsp'); + } else { + context.subscriptions.push(new languageService.LanguageService()); + } + } + // initialize the package/help related functions + globalRHelp = rHelp.initializeHelp(context, rExtension); +} diff --git a/src/helpViewer/cran.ts b/src/helpViewer/cran.ts index 4050cf24d..ec6c12379 100644 --- a/src/helpViewer/cran.ts +++ b/src/helpViewer/cran.ts @@ -84,7 +84,7 @@ function parseCranTable(html: string, baseUrl: string): Package[] { tables.each((tableIndex, table) => { const rows = $('tr', table); rows.each((rowIndex, row) => { - if (rowIndex === 0) return; // Skip the header row + if (rowIndex === 0) {return;} // Skip the header row const date = $(row).find('td:nth-child(1)').text().trim(); const href = $(row).find('td:nth-child(2) a').attr('href'); const url = href ? new URL(href, baseUrl).toString() : undefined; diff --git a/src/helpViewer/helpPreviewer.ts b/src/helpViewer/helpPreviewer.ts index 75268f7da..2d7b98693 100644 --- a/src/helpViewer/helpPreviewer.ts +++ b/src/helpViewer/helpPreviewer.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; import * as rHelp from './index'; import * as ejs from 'ejs'; -import { isDirSafe, isFileSafe, readFileSyncSafe, config, spawnAsync } from '../util'; +import { isDirSafe, isFileSafe, readFileSyncSafe, config, spawnRAsync } from '../util'; import { Topic, TopicType } from './packages'; @@ -247,7 +247,7 @@ export class RLocalHelpPreviewer { this.getPackageInfo()?.version || DUMMY_TOPIC_VERSION, this.packageDir ]; - const spawnRet = await spawnAsync(this.rPath, args); + const spawnRet = await spawnRAsync(this.rPath, args); if(spawnRet.status){ // The user expects this to work, so we show a warning if it doesn't: const msg = `Failed to convert .Rd file ${rdFileName} (status: ${spawnRet.status}): ${spawnRet.stderr}`; @@ -409,7 +409,7 @@ function extractRPaths(rdTxt: string): string[] | undefined { if(firstRealLine >= 0){ lines.splice(firstRealLine); } - + // Join lines that were split (these start with "% ") const CONTINUED_LINE_START = '% '; const longLines = []; @@ -420,7 +420,7 @@ function extractRPaths(rdTxt: string): string[] | undefined { longLines.push(line); } } - + // Find the line that references R files for(const line of longLines){ const rFileMatch = line.match(/^% Please edit documentation in (.*)$/); diff --git a/src/helpViewer/helpProvider.ts b/src/helpViewer/helpProvider.ts index 20bcac9de..7df88e832 100644 --- a/src/helpViewer/helpProvider.ts +++ b/src/helpViewer/helpProvider.ts @@ -4,7 +4,7 @@ import * as cp from 'child_process'; import * as rHelp from '.'; import { extensionContext } from '../extension'; -import { catchAsError, config, DisposableProcess, getRLibPaths, spawn, spawnAsync } from '../util'; +import { catchAsError, config, DisposableProcess, getRLibPaths, spawnR, spawnRAsync } from '../util'; export interface RHelpProviderOptions { // path of the R executable @@ -67,7 +67,7 @@ export class HelpProvider { }, }; - const childProcess: ChildProcessWithPort = spawn(this.rPath, args, cpOptions); + const childProcess: ChildProcessWithPort = spawnR(this.rPath, args, cpOptions); let str = ''; // promise containing the port number of the process (or 0) @@ -267,7 +267,7 @@ export class AliasProvider { ]; try { - const result = await spawnAsync(this.rPath, args, options); + const result = await spawnRAsync(this.rPath, args, options); if (result.status !== 0) { throw result.error || new Error(result.stderr); } diff --git a/src/helpViewer/index.ts b/src/helpViewer/index.ts index 227e9cdde..e2e61b1d2 100644 --- a/src/helpViewer/index.ts +++ b/src/helpViewer/index.ts @@ -39,15 +39,15 @@ export const codeClickConfigDefault = { }; // Initialization function that is called once when activating the extension -export async function initializeHelp( +export function initializeHelp( context: vscode.ExtensionContext, rExtension: api.RExtension, -): Promise { +): RHelp | undefined { // set context value to indicate that the help related tree-view should be shown void vscode.commands.executeCommand('setContext', 'r.helpViewer.show', true); // get the "vanilla" R path from config - const rPath = await getRpath(); + const rPath = getRpath(); if(!rPath){ return undefined; } @@ -177,7 +177,7 @@ export interface HelpFile { // Internal representation of an "Alias" export interface Alias { - // main name of a help topic + // main name of a help topic name: string // one of possibly many aliases of the same help topic alias: string @@ -223,7 +223,7 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer ( a1.package === a2.package @@ -638,7 +638,7 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { for (const previewer of this.previewProviders) { const ret = await previewer.getHelpFileFromRequestPath(requestPath); @@ -688,7 +688,7 @@ function pimpMyHelp(helpFile: HelpFile): HelpFile { helpFile.html = `
${html}
`; helpFile.isModified = true; } - + // parse the html string for futher modifications const $ = cheerio.load(helpFile.html); diff --git a/src/helpViewer/packages.ts b/src/helpViewer/packages.ts index 82cee13da..f4434e78c 100644 --- a/src/helpViewer/packages.ts +++ b/src/helpViewer/packages.ts @@ -3,7 +3,7 @@ import * as cheerio from 'cheerio'; import * as vscode from 'vscode'; import { RHelp } from '.'; -import { getConfirmation, executeAsTask, doWithProgress, getCranUrl } from '../util'; +import { getConfirmation, doWithProgress, getCranUrl, executeAsRTask } from '../util'; import { getPackagesFromCran } from './cran'; @@ -198,8 +198,8 @@ export class PackageManager { const confirmation = 'Yes, remove package!'; const prompt = `Are you sure you want to remove package ${pkgName}?`; - if(await getConfirmation(prompt, confirmation, cmd)){ - await executeAsTask('Remove Package', rPath, args, true); + if (await getConfirmation(prompt, confirmation, cmd)) { + await executeAsRTask('Remove Package', rPath, args, true); return true; } else{ return false; @@ -217,8 +217,8 @@ export class PackageManager { const confirmation = `Yes, install package${pluralS}!`; const prompt = `Are you sure you want to install package${pluralS}: ${pkgNames.join(', ')}?`; - if(skipConfirmation || await getConfirmation(prompt, confirmation, cmd)){ - await executeAsTask('Install Package', rPath, args, true); + if (skipConfirmation || await getConfirmation(prompt, confirmation, cmd)) { + await executeAsRTask('Install Package', rPath, args, true); return true; } return false; @@ -233,7 +233,7 @@ export class PackageManager { const prompt = 'Are you sure you want to update all installed packages? This might take some time!'; if(skipConfirmation || await getConfirmation(prompt, confirmation, cmd)){ - await executeAsTask('Update Packages', rPath, args, true); + await executeAsRTask('Update Packages', rPath, args, true); return true; } else{ return false; diff --git a/src/languageService.ts b/src/languageService.ts index 96879d5ad..f902e6f1c 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -4,7 +4,7 @@ import * as net from 'net'; import { URL } from 'url'; 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 { DisposableProcess, getRLibPaths, getRpath, promptToInstallRPackage, spawnR, substituteVariables } from './util'; import { extensionContext } from './extension'; import { CommonOptions } from 'child_process'; @@ -27,7 +27,7 @@ export class LanguageService implements Disposable { } private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }): DisposableProcess { - const childProcess = spawn(rPath, args, options); + const childProcess = spawnR(rPath, args, options); const pid = childProcess.pid || -1; client.outputChannel.appendLine(`R Language Server (${pid}) started`); childProcess.stderr.on('data', (chunk: Buffer) => { @@ -60,7 +60,7 @@ export class LanguageService implements Disposable { const debug = config.get('lsp.debug'); const useRenvLibPath = config.get('useRenvLibPath') ?? false; - const rPath = await getRpath() || ''; // TODO: Abort gracefully + const rPath = getRpath() || ''; // TODO: Abort gracefully if (debug) { console.log(`R path: ${rPath}`); } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 22329a730..037d72110 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -5,14 +5,14 @@ import { isDeepStrictEqual } from 'util'; import * as vscode from 'vscode'; -import { extensionContext, homeExtDir } from './extension'; +import { extensionContext, homeExtDir, rExecutableManager } from './extension'; import * as util from './util'; import * as selection from './selection'; import { getSelection } from './selection'; import { cleanupSession } from './session'; -import { config, delay, getRterm, getCurrentWorkspaceFolder } from './util'; +import { config, delay, getCurrentWorkspaceFolder } from './util'; import { rGuestService, isGuestSession } from './liveShare'; -import * as fs from 'fs'; +import { setupVirtualAwareProcessArguments } from './executables'; export let rTerm: vscode.Terminal | undefined = undefined; export async function runSource(echo: boolean): Promise { @@ -114,16 +114,21 @@ export async function runFromLineToEnd(): Promise { await runTextInTerm(text); } -export async function makeTerminalOptions(): Promise { +export function makeTerminalOptions(): vscode.TerminalOptions { + const currentExecutable = rExecutableManager?.activeExecutable; + if (!currentExecutable) { return {}; } + const workspaceFolderPath = getCurrentWorkspaceFolder()?.uri.fsPath; - const termPath = await getRterm(); const shellArgs: string[] = config().get('rterm.option')?.map(util.substituteVariables) || []; + + const processArgs = setupVirtualAwareProcessArguments(currentExecutable, true, shellArgs); const termOptions: vscode.TerminalOptions = { name: 'R Interactive', - shellPath: termPath, - shellArgs: shellArgs, - cwd: workspaceFolderPath, + shellPath: processArgs.cmd, + shellArgs: processArgs.args, + cwd: workspaceFolderPath }; + const newRprofile = extensionContext.asAbsolutePath(path.join('R', 'session', 'profile.R')); const initR = extensionContext.asAbsolutePath(path.join('R', 'session','init.R')); if (config().get('sessionWatcher')) { @@ -137,26 +142,29 @@ export async function makeTerminalOptions(): Promise { return termOptions; } -export async function createRTerm(preserveshow?: boolean): Promise { - const termOptions = await makeTerminalOptions(); + +// TODO +export function createRTerm(preserveshow?: boolean): boolean { + const termOptions = makeTerminalOptions(); const termPath = termOptions.shellPath; if(!termPath){ void vscode.window.showErrorMessage('Could not find R path. Please check r.term and r.path setting.'); return false; - } else if(!fs.existsSync(termPath)){ - void vscode.window.showErrorMessage(`Cannot find R client at ${termPath}. Please check r.rterm setting.`); - return false; } + // else if (!fs.existsSync(termPath)) { + // void vscode.window.showErrorMessage(`Cannot find R client at ${termPath}. Please check r.rterm setting.`); + // return false; + // } rTerm = vscode.window.createTerminal(termOptions); rTerm.show(preserveshow); return true; } -export async function restartRTerminal(): Promise{ +export function restartRTerminal(): void { if (typeof rTerm !== 'undefined'){ rTerm.dispose(); deleteTerminal(rTerm); - await createRTerm(true); + createRTerm(true); } } @@ -229,7 +237,7 @@ export async function chooseTerminal(): Promise { } if (rTerm === undefined) { - await createRTerm(true); + createRTerm(true); await delay(200); // Let RTerm warm up } diff --git a/src/rmarkdown/draft.ts b/src/rmarkdown/draft.ts index 172eb1b68..eb12f10b0 100644 --- a/src/rmarkdown/draft.ts +++ b/src/rmarkdown/draft.ts @@ -1,6 +1,6 @@ import { QuickPickItem, QuickPickOptions, Uri, window, workspace, env } from 'vscode'; import { extensionContext } from '../extension'; -import { executeRCommand, getCurrentWorkspaceFolder, getRpath, ToRStringLiteral, spawnAsync, getConfirmation, catchAsError } from '../util'; +import { executeRCommand, getCurrentWorkspaceFolder, getRpath, ToRStringLiteral, spawnRAsync, getConfirmation, catchAsError } from '../util'; import * as cp from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; @@ -20,7 +20,7 @@ interface TemplateItem extends QuickPickItem { async function getTemplateItems(cwd: string): Promise { const lim = '---vsc---'; - const rPath = await getRpath(); + const rPath = getRpath(); if (!rPath) { return undefined; } @@ -43,7 +43,7 @@ async function getTemplateItems(cwd: string): Promise { + childProcess = spawnR(this.rPath, cpArgs, processOptions, () => { rMarkdownOutput.appendLine('[VSC-R] terminating R process'); printOutput = false; }); diff --git a/src/rmarkdown/preview.ts b/src/rmarkdown/preview.ts index b9d5984d6..8f26780dc 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 = getRpath(); const lim = '<<>>'; const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'ms'); diff --git a/src/rstudioapi.ts b/src/rstudioapi.ts index ac4d738e5..77cba324e 100644 --- a/src/rstudioapi.ts +++ b/src/rstudioapi.ts @@ -75,7 +75,7 @@ export async function dispatchRStudioAPICall(action: string, args: any, sd: stri break; } case 'restart_r': { - await restartRTerminal(); + restartRTerminal(); await writeSuccessResponse(sd); break; } @@ -254,7 +254,7 @@ export function projectPath(): { path: string | undefined; } { } export async function documentNew(text: string, type: string, position: number[]): Promise { - const currentProjectPath = projectPath().path; + const currentProjectPath = projectPath().path; if (!currentProjectPath) { return; // TODO: Report failure } diff --git a/src/tasks.ts b/src/tasks.ts index 1062ee438..b5fb8a79a 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -3,7 +3,8 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; -import { getRpath } from './util'; +import { RExecutableType, setupVirtualAwareProcessArguments } from './executables'; +import { rExecutableManager } from './extension'; const TYPE = 'R'; @@ -106,16 +107,18 @@ const rtasks: RTaskInfo[] = [ } ]; -function asRTask(rPath: string, folder: vscode.WorkspaceFolder | vscode.TaskScope, info: RTaskInfo): vscode.Task { +function asRTask(executable: RExecutableType, folder: vscode.WorkspaceFolder | vscode.TaskScope, info: RTaskInfo): vscode.Task { const args = makeRArgs(info.definition.options ?? defaultOptions, info.definition.code); + const processArgs = setupVirtualAwareProcessArguments(executable, false, args); + const rtask: vscode.Task = new vscode.Task( info.definition, folder, info.name ?? 'Unnamed', info.definition.type, new vscode.ProcessExecution( - rPath, - args, + processArgs.cmd, + processArgs.args ?? [], { cwd: info.definition.cwd, env: info.definition.env @@ -132,7 +135,7 @@ export class RTaskProvider implements vscode.TaskProvider { public type = TYPE; - public async provideTasks(): Promise { + public provideTasks(): vscode.Task[] { const folders = vscode.workspace.workspaceFolders; if (!folders) { @@ -140,8 +143,8 @@ export class RTaskProvider implements vscode.TaskProvider { } const tasks: vscode.Task[] = []; - const rPath = await getRpath(false); - if (!rPath) { + const rexecutable = rExecutableManager?.activeExecutable; + if (!rexecutable) { return []; } @@ -149,7 +152,7 @@ export class RTaskProvider implements vscode.TaskProvider { const isRPackage = fs.existsSync(path.join(folder.uri.fsPath, 'DESCRIPTION')); if (isRPackage) { for (const rtask of rtasks) { - const task = asRTask(rPath, folder, rtask); + const task = asRTask(rexecutable, folder, rtask); tasks.push(task); } } @@ -157,16 +160,17 @@ export class RTaskProvider implements vscode.TaskProvider { return tasks; } - public async resolveTask(task: vscode.Task): Promise { + public resolveTask(task: vscode.Task): vscode.Task { const taskInfo: RTaskInfo = { definition: task.definition, group: task.group, name: task.name }; - const rPath = await getRpath(false); - if (!rPath) { + const rexecutable = rExecutableManager?.activeExecutable; + if (!rexecutable) { + void vscode.window.showErrorMessage('Cannot run task. No valid R executable path set.'); throw 'R path not set.'; } - return asRTask(rPath, vscode.TaskScope.Workspace, taskInfo); + return asRTask(rexecutable, vscode.TaskScope.Workspace, taskInfo); } } diff --git a/src/test/suite/executable.test.ts b/src/test/suite/executable.test.ts new file mode 100644 index 000000000..661e61b38 --- /dev/null +++ b/src/test/suite/executable.test.ts @@ -0,0 +1,133 @@ +import * as sinon from 'sinon'; +import * as path from 'path'; +import * as assert from 'assert'; + + +import * as ext from '../../extension'; +import * as exec from '../../executables'; +import { ExecutableStatusItem } from '../../executables/ui'; +import { mockExtensionContext } from '../common'; +import { RExecutablePathStorage } from '../../executables/service/pathStorage'; +import { DummyMemento } from '../../util'; +import { IProcessArgs, setupVirtualAwareProcessArguments } from '../../executables'; +import { RExecutable, RExecutableService, CondaVirtualRExecutable } from '../../executables/service'; + +const extension_root: string = path.join(__dirname, '..', '..', '..'); + +suite('Language Status Item', () => { + let sandbox: sinon.SinonSandbox; + setup(() => { + sandbox = sinon.createSandbox(); + }); + teardown(() => { + sandbox.restore(); + }); + + test('text', () => { + mockExtensionContext(extension_root, sandbox); + let executableValue: exec.RExecutableType | undefined = undefined; + const statusItem = new ExecutableStatusItem({ + get activeExecutable() { + return executableValue; + } + } as unknown as RExecutableService); + assert.strictEqual( + statusItem.text, + '$(warning) Select R executable' + ); + + executableValue = { + get tooltip(): string { + return `R 4.0 64-bit`; + }, + rVersion: '4.0' + } as exec.RExecutableType; + statusItem.refresh(); + assert.strictEqual( + statusItem.text, + '4.0' + ); + statusItem.dispose(); + }); +}); + +suite('Executable Path Storage', () => { + let sandbox: sinon.SinonSandbox; + setup(() => { + sandbox = sinon.createSandbox(); + }); + teardown(() => { + sandbox.restore(); + }); + test('path storage + retrieval', () => { + const mockExtensionContext = { + environmentVariableCollection: sandbox.stub(), + extension: sandbox.stub(), + extensionMode: sandbox.stub(), + extensionPath: sandbox.stub(), + extensionUri: sandbox.stub(), + globalState: new DummyMemento(), + globalStorageUri: sandbox.stub(), + logUri: sandbox.stub(), + secrets: sandbox.stub(), + storageUri: sandbox.stub(), + subscriptions: [], + workspaceState: { + get: sinon.stub(), + update: sinon.stub() + }, + asAbsolutePath: (relativePath: string) => { + return path.join(extension_root, relativePath); + } + }; + sandbox.stub(ext, 'extensionContext').value(mockExtensionContext); + const pathStorage = new RExecutablePathStorage(); + pathStorage.setExecutablePath('/working/1', '/bin/1'); + assert.strictEqual( + pathStorage.getExecutablePath('/working/1'), + '/bin/1' + ); + + const pathStorage2 = new RExecutablePathStorage(); + assert.strictEqual( + pathStorage2.getExecutablePath('/working/1'), + '/bin/1' + ); + }); +}); + +suite('Virtuals', () => { + let sandbox: sinon.SinonSandbox; + setup(() => { + sandbox = sinon.createSandbox(); + }); + teardown(() => { + sandbox.restore(); + }); + test('virtual aware args', () => { + let args: IProcessArgs; + const rArgs = ['--vanilla']; + + const realExecutable = new RExecutable('/dummy/path/R'); + args = setupVirtualAwareProcessArguments(realExecutable, false, rArgs); + assert.deepEqual(args, { + args: [ + '--vanilla' + ], + cmd: '/dummy/path/R' + }); + + const virtualExecutable = new CondaVirtualRExecutable('/dummy/conda/path/R'); + args = setupVirtualAwareProcessArguments(virtualExecutable, false, rArgs); + assert.deepEqual(args, { + args: [ + 'run', + '-n', + '', + '/dummy/conda/path/R', + '--vanilla' + ], + cmd: 'conda' + }); + }); +}); diff --git a/src/util.ts b/src/util.ts index 2f2ab6b0b..c893aad99 100644 --- a/src/util.ts +++ b/src/util.ts @@ -3,13 +3,13 @@ import { homedir } from 'os'; import { existsSync, PathLike, readFile } from 'fs-extra'; import * as fs from 'fs'; -import winreg = require('winreg'); import * as path from 'path'; import * as vscode from 'vscode'; import * as cp from 'child_process'; import { rGuestService, isGuestSession } from './liveShare'; -import { extensionContext } from './extension'; +import { extensionContext, rExecutableManager } from './extension'; import { randomBytes } from 'crypto'; +import { RExecutableType, setupVirtualAwareProcessArguments } from './executables'; export function config(): vscode.WorkspaceConfiguration { return vscode.workspace.getConfiguration('r'); @@ -41,50 +41,6 @@ export function substituteVariables(str: string): string { return result; } -function getRfromEnvPath(platform: string) { - 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, 'R' + fileExtension); - if (fs.existsSync(os_r_path)) { - return os_r_path; - } - } - return ''; -} - -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; -} - export function getRPathConfigEntry(term: boolean = false): string { const trunc = (term ? 'rterm' : 'rpath'); const platform = ( @@ -95,28 +51,13 @@ export function getRPathConfigEntry(term: boolean = false): string { return `${trunc}.${platform}`; } -export async function getRpath(quote = false, overwriteConfig?: string): Promise { +export function getRpath(quote = false): string | undefined { 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; + rpath = rExecutableManager?.activeExecutablePath; 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.`); + void vscode.window.showErrorMessage(`Cannot find R to use for help, package installation etc. Set executable path to R path.`); } else if (quote && /^[^'"].* .*[^'"]$/.exec(rpath)) { // if requested and rpath contains spaces, add quotes: rpath = `"${rpath}"`; @@ -131,11 +72,13 @@ export async function getRpath(quote = false, overwriteConfig?: string): Promise return rpath; } -export async function getRterm(): Promise { +// TODO +export function getRterm(): string | undefined { const configEntry = getRPathConfigEntry(true); let rpath = config().get(configEntry); rpath &&= substituteVariables(rpath); - rpath ||= await getRpathFromSystem(); + // rpath ||= await getRpathFromSystem(); + rpath ||= getRpath(); if (rpath !== '') { return rpath; @@ -293,6 +236,22 @@ export async function executeAsTask(name: string, cmdOrProcess: string, args?: s return await taskDonePromise; } +// todo +export async function executeAsRTask(name: string, executable: string, args?: string[], asProcess?: true): Promise; +export async function executeAsRTask(name: string, executable: RExecutableType, args?: string[], asProcess?: true): Promise; +export async function executeAsRTask(name: string, executable: RExecutableType | string, args?: string[], asProcess?: true): Promise { + const rexecutable: RExecutableType | undefined = + typeof executable === 'string' ? + rExecutableManager?.getExecutableFromPath(executable) : executable; + if (!rexecutable) { + throw 'Bad R executable supplied'; + } + + const processArgs = setupVirtualAwareProcessArguments(rexecutable, false, args); + return executeAsTask(name, processArgs.cmd, processArgs.args, asProcess); +} + + // executes a callback and shows a 'busy' progress bar during the execution // synchronous callbacks are converted to async to properly render the progress bar // default location is in the help pages tree view @@ -340,7 +299,7 @@ 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 rPath = getRpath(); if (!rPath) { return undefined; } @@ -531,6 +490,17 @@ export async function spawnAsync(command: string, args?: ReadonlyArray, }); } +export function spawnR(rpath: string, args?: ReadonlyArray, options?: cp.CommonOptions, onDisposed?: () => unknown): DisposableProcess { + const spawnArgs = setupVirtualAwareProcessArguments(rpath, false, args); + return spawn(spawnArgs.cmd, spawnArgs.args, options, onDisposed); +} + +export function spawnRAsync(rpath: string, args?: ReadonlyArray, options?: cp.CommonOptions, onDisposed?: () => unknown): Promise> { + const spawnArgs = setupVirtualAwareProcessArguments(rpath, false, args); + return spawnAsync(spawnArgs.cmd, spawnArgs.args, options, onDisposed); +} + + /** * Check if an R package is available or not * @@ -550,13 +520,14 @@ 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 = getRpath(); if (!rPath) { void vscode.window.showErrorMessage('R path not set', 'OK'); return; } const args = ['--silent', '--slave', '--no-save', '--no-restore', '-e', `install.packages('${name}', repos='${repo}')`]; - void executeAsTask('Install Package', rPath, args, true); + const processArgs = setupVirtualAwareProcessArguments(rPath, true, args); + void executeAsTask('Install Package', processArgs.cmd, processArgs.args, true); if (postInstallMsg) { void vscode.window.showInformationMessage(postInstallMsg, 'OK'); } @@ -674,3 +645,16 @@ export function uniqueEntries(array: T[], isIdentical: (x: T, y: T) => boolea } return array.filter(uniqueFunction); } + +export function normaliseRPathString(path: string): string { + return path.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'); +} + +export function isMultiRoot(): boolean { + const folders = vscode?.workspace?.workspaceFolders; + if (folders) { + return folders.length > 1; + } else { + return false; + } +}