diff --git a/package.json b/package.json index 761a37ae2..4bc747016 100644 --- a/package.json +++ b/package.json @@ -322,6 +322,11 @@ } ], "commands": [ + { + "category": "R", + "command": "r.setExecutable", + "title": "Select executable" + }, { "command": "r.workspaceViewer.refreshEntry", "title": "Manual Refresh", @@ -1317,35 +1322,53 @@ "type": "object", "title": "R", "properties": { + "r.executable.virtual.activateEnvironment": { + "type": "boolean", + "default": true, + "description": "Whether to automatically activate a conda environment containing a selected executable. *Recommended to keep enabled*", + "scope": "machine-overridable" + }, + "r.executable.virtual.condaPath": { + "type": "string", + "default": "", + "description": "Path to conda activation script. This will be `conda.sh` on unix or the `Activate` file on windows.", + "scope": "machine-overridable" + }, "r.rpath.windows": { "type": "string", "default": "", - "description": "Path to an R executable to launch R background processes (Windows). Must be \"vanilla\" R, not radian etc.!" + "description": "Path to an R executable to launch R background processes (Windows). Must be \"vanilla\" R, not radian etc.!", + "scope": "machine-overridable" }, "r.rpath.mac": { "type": "string", "default": "", - "description": "Path to an R executable to launch R background processes (macOS). Must be \"vanilla\" R, not radian etc.!" + "description": "Path to an R executable to launch R background processes (macOS). Must be \"vanilla\" R, not radian etc.!", + "scope": "machine-overridable" }, "r.rpath.linux": { "type": "string", "default": "", - "description": "Path to an R executable to launch R background processes (Linux). Must be \"vanilla\" R, not radian etc.!" + "description": "Path to an R executable to launch R background processes (Linux). Must be \"vanilla\" R, not radian etc.!", + "scope": "machine-overridable" }, "r.rterm.windows": { "type": "string", "default": "", - "description": "R path for interactive terminals (Windows). Can also be radian etc." + "description": "R path for interactive terminals (Windows). Can also be radian etc.", + "scope": "machine-overridable" }, "r.rterm.mac": { "type": "string", "default": "", - "description": "R path for interactive terminals (macOS). Can also be radian etc." + "description": "R path for interactive terminals (macOS). Can also be radian etc.", + "scope": "machine-overridable" }, "r.rterm.linux": { "type": "string", "default": "", - "description": "R path for interactive terminals (Linux). Can also be radian etc." + "description": "R path for interactive terminals (Linux). Can also be radian etc.", + "scope": "machine-overridable" }, "r.rterm.option": { "type": "array", @@ -2044,4 +2067,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/index.ts b/src/executables/index.ts new file mode 100644 index 000000000..a0a3bbbef --- /dev/null +++ b/src/executables/index.ts @@ -0,0 +1,147 @@ +import path = require('path'); +import * as fs from 'fs-extra'; +import * as vscode from 'vscode'; +import * as cp from 'child_process'; + +import { ExecutableStatusItem, ExecutableQuickPick } from './ui'; +import { isVirtual, RExecutableService, RExecutableType, WorkspaceExecutableEvent } from './service'; +import { extensionContext } from '../extension'; +import { activateCondaEnvironment, condaPrefixPath } from './virtual'; + +// 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 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 and + * activates a conda environment if present. + * @memberof RExecutableManager + */ + public reload(): void { + this.statusBar.refresh(); + const loading = this.activateEnvironment(); + void this.statusBar.makeBusy(loading); + } + + /** + * Activates a Conda environment, but only if the currently active executable is virtual + * and has no obtained environmental variable. If determined that activation is not necessary, + * a resolved promise will be returned. + */ + private async activateEnvironment(): Promise { + if (!this.activeExecutable || + !isVirtual(this.activeExecutable) || + !!this.activeExecutable.envVar + ) { + return Promise.resolve(); + } + await activateCondaEnvironment(this.activeExecutable); + } + +} + +/** + * 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; + } +} + + +/** + * @description + * Takes an options object, and modifies the env values to allow for the injection + * of conda env values, and modify R binary paths for various rterms (e.g. radian) + * @export + * @template T + * @param {T} opts + * @param {RExecutableType} executable + * @returns {*} {T} + */ +export function modifyEnvVars(opts: T, executable: RExecutableType): T { + const envVars: Record = { + R_BINARY: executable.rBin + }; + const pathEnv: string = (opts?.env as Record)?.PATH ?? process.env?.PATH; + if (isVirtual(executable) && executable.envVar) { + pathEnv ? + envVars['PATH'] = `${executable.envVar}:${pathEnv}` + : + envVars['PATH'] = executable.envVar; + envVars['CONDA_PREFIX'] = condaPrefixPath(executable.rBin); + envVars['CONDA_DEFAULT_ENV'] = executable.name ?? 'base'; + envVars['CONDA_PROMPT_MODIFIER'] = `(${envVars['CONDA_DEFAULT_ENV']})`; + } + opts['env'] = envVars; + return opts; +} \ No newline at end of file diff --git a/src/executables/service/class.ts b/src/executables/service/class.ts new file mode 100644 index 000000000..4c5de1fa2 --- /dev/null +++ b/src/executables/service/class.ts @@ -0,0 +1,98 @@ +import { condaName, getRDetailsFromMetaHistory, isCondaInstallation } from '../virtual'; +import { getRDetailsFromPath } from './locator'; +import { RExecutableRegistry } from './registry'; +import { RExecutableType } from './types'; + +export function isVirtual(executable: AbstractRExecutable): executable is VirtualRExecutable { + return executable instanceof VirtualRExecutable; +} + +/** + * 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 VirtualRExecutable(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 class VirtualRExecutable extends AbstractRExecutable { + private _name: string; + public envVar!: string; + + constructor(executablePath: string) { + super(); + this._name = condaName(executablePath); + const details = getRDetailsFromMetaHistory(executablePath); + this._rVersion = details?.version ?? ''; + this._rArch = details?.arch ?? ''; + this._rBin = executablePath; + } + + 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}'`; + } + +} diff --git a/src/executables/service/index.ts b/src/executables/service/index.ts new file mode 100644 index 000000000..6e89d20bd --- /dev/null +++ b/src/executables/service/index.ts @@ -0,0 +1,224 @@ +import * as vscode from 'vscode'; + +import { validateRExecutablePath } from '..'; +import { RExecutableFactory } from './class'; +import { config, getCurrentWorkspaceFolder, getRPathConfigEntry } from '../../util'; +import { RExecutablePathStorage } from './pathStorage'; +import { RExecutableRegistry } from './registry'; +import { AbstractLocatorService, LocatorServiceFactory } from './locator'; +import { getRenvVersion } from '../virtual'; +import { RExecutableType, WorkspaceExecutableEvent } from './types'; + +export * from './types'; +export * from './class'; + +/** + * @description + * @export + * @class RExecutableService + * @implements {vscode.Disposable} + */ +export class RExecutableService implements vscode.Disposable { + public executableFactory: RExecutableFactory; + public executablePathLocator: AbstractLocatorService; + 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: AbstractLocatorService) { + 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 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 confPath = config().get(getRPathConfigEntry()); + if (vscode.workspace.workspaceFolders) { + for (const workspace of vscode.workspace.workspaceFolders) { + const workspacePath = workspace.uri.path; + 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; + })[0]; + this.workspaceExecutables.set(workspacePath, exec); + 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); + } + } + } + } else { + // todo + } + } +} + + diff --git a/src/executables/service/locator/index.ts b/src/executables/service/locator/index.ts new file mode 100644 index 000000000..5e6091270 --- /dev/null +++ b/src/executables/service/locator/index.ts @@ -0,0 +1,27 @@ +export * from './shared'; + +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() { + // + } +} diff --git a/src/executables/service/locator/shared.ts b/src/executables/service/locator/shared.ts new file mode 100644 index 000000000..2d5810d21 --- /dev/null +++ b/src/executables/service/locator/shared.ts @@ -0,0 +1,80 @@ +import { spawnSync } from 'child_process'; +import * as fs from 'fs-extra'; +import * as vscode from 'vscode'; +import { normaliseRPathString } from '../../../util'; + +/** + * 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); + 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: '' }; + } +} + +/** + * 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; + } +} diff --git a/src/executables/service/locator/unix.ts b/src/executables/service/locator/unix.ts new file mode 100644 index 000000000..8205ae0c8 --- /dev/null +++ b/src/executables/service/locator/unix.ts @@ -0,0 +1,92 @@ +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import path = require('path'); +import { getUniquePaths } from './shared'; +import { AbstractLocatorService } 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; + } +} + diff --git a/src/executables/service/locator/windows.ts b/src/executables/service/locator/windows.ts new file mode 100644 index 000000000..2240118cc --- /dev/null +++ b/src/executables/service/locator/windows.ts @@ -0,0 +1,147 @@ +import * as fs from 'fs-extra'; +import * as vscode from 'vscode'; +import os = require('os'); +import path = require('path'); +import winreg = require('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) { + await new Promise( + (c, e) => { + reg.get('InstallPath', (err, result) => err === null ? c(result) : e(err)); + } + ).then((item: unknown) => { + if (item) { + const resolvedPath = (item 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; + } +} diff --git a/src/executables/service/pathStorage.ts b/src/executables/service/pathStorage.ts new file mode 100644 index 000000000..1c93d259a --- /dev/null +++ b/src/executables/service/pathStorage.ts @@ -0,0 +1,73 @@ +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..84434b720 --- /dev/null +++ b/src/executables/service/registry.ts @@ -0,0 +1,31 @@ +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); + } +} diff --git a/src/executables/service/types.ts b/src/executables/service/types.ts new file mode 100644 index 000000000..2106f1e6b --- /dev/null +++ b/src/executables/service/types.ts @@ -0,0 +1,21 @@ +import * as vscode from 'vscode'; + +import { AbstractRExecutable, VirtualRExecutable } from './class'; + +export type RExecutableType = AbstractRExecutable; +export type VirtualRExecutableType = VirtualRExecutable; + +export interface IExecutableDetails { + version: string | undefined, + arch: string | undefined +} + +/** + * @description + * @export + * @interface WorkspaceExecutableEvent + */ +export interface WorkspaceExecutableEvent { + workingFolder: vscode.WorkspaceFolder | undefined, + executable: RExecutableType | undefined +} diff --git a/src/executables/ui/index.ts b/src/executables/ui/index.ts new file mode 100644 index 000000000..c404837c2 --- /dev/null +++ b/src/executables/ui/index.ts @@ -0,0 +1,2 @@ +export { ExecutableQuickPick } from './quickpick'; +export { ExecutableStatusItem } from './status'; diff --git a/src/executables/ui/quickpick.ts b/src/executables/ui/quickpick.ts new file mode 100644 index 000000000..94d453b31 --- /dev/null +++ b/src/executables/ui/quickpick.ts @@ -0,0 +1,313 @@ +import path = require('path'); +import * as vscode from 'vscode'; + +import { validateRExecutablePath } from '..'; +import { config, getCurrentWorkspaceFolder, getRPathConfigEntry, isMultiRoot } from '../../util'; +import { isVirtual, RExecutableType } from '../service'; +import { RExecutableService } from '../service'; +import { getRenvVersion } from '../virtual'; +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[] = []; + const configPath = config().get(getRPathConfigEntry()); + 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 = config().get(getRPathConfigEntry()); + 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..233d6352e --- /dev/null +++ b/src/executables/ui/status.ts @@ -0,0 +1,85 @@ +import * as vscode from 'vscode'; + +import { isVirtual } from '../service/class'; +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 languageStatusItem!: vscode.LanguageStatusItem; + + private createItem(): vscode.LanguageStatusItem { + 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(); + return this.languageStatusItem; + } + + public constructor(service: RExecutableService) { + this.service = service; + this.createItem(); + } + + 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 async makeBusy(prom: Promise): Promise { + this.languageStatusItem.busy = true; + await prom.catch(() => { + this.languageStatusItem.severity = vscode.LanguageStatusSeverity.Error; + this.languageStatusItem.detail = '$(error) Error activating virtual environment'; + }).finally(() => { + this.languageStatusItem.busy = false; + }); + } + + public dispose(): void { + this.languageStatusItem.dispose(); + } + +} diff --git a/src/executables/virtual/conda.ts b/src/executables/virtual/conda.ts new file mode 100644 index 000000000..7a40f06f1 --- /dev/null +++ b/src/executables/virtual/conda.ts @@ -0,0 +1,117 @@ + +import * as fs from 'fs-extra'; +import * as vscode from 'vscode'; +import path = require('path'); +import { IExecutableDetails, VirtualRExecutableType } from '../service'; +import { exec } from 'child_process'; +import { tmpDir } from '../../extension'; +import { config } from '../../util'; + +// Misc + +export function condaName(executablePath: string): string { + return path.basename(condaPrefixPath(executablePath)); +} + +// Path functions + +export 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'); +} + +function condaActivationPath(executablePath: string): string { + const condaPathConfig = config().get('virtual.condaPath'); + if (condaPathConfig) { + return condaPathConfig; + } else if (process.platform === 'win32') { + const envDir = path.dirname(condaMetaDirPath(executablePath)); + return path.join(path.dirname(path.dirname(envDir)), 'Scripts', 'activate'); + } else { + return path.join('/', 'usr', 'bin', 'activate'); + } +} + +// Bools + +export function environmentIsActive(executablePath: string): boolean { + return process.env.CONDA_DEFAULT_ENV === condaName(executablePath) || + process.env.CONDA_PREFIX === condaPrefixPath(executablePath); +} + +export function isCondaInstallation(executablePath: string): boolean { + return fs.existsSync(condaMetaDirPath(executablePath)); +} + +// Extension + +export function getRDetailsFromMetaHistory(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 activateCondaEnvironment(executable: VirtualRExecutableType): Promise { + return new Promise((resolve, reject) => { + try { + let command: string; + // need to fake activating conda environment by adding its env vars to the relevant R processes + const activationPath = condaActivationPath(executable.rBin); + + if (process.platform === 'win32') { + // this assumes no powershell usage + // todo! need to check env saving for windows + command = [ + activationPath, + `conda activate ${executable.name}`, + `echo $PATH | awk -F':' '{ print $1}' > ${tmpDir()}/${executable.name}Env.txt` + ].join(' && '); + } else { + command = [ + `source ${activationPath}`, + `conda activate ${executable.name}`, + `echo $PATH | awk -F':' '{ print $1}' > ${tmpDir()}/${executable.name}Env.txt` + ].join(' &&'); + } + const childProc = exec(command); + + childProc.on('error', (err) => { + void vscode.window.showErrorMessage(`Error when activating conda environment: ${err.message}`); + reject(); + }); + childProc.on('exit', () => { + executable.envVar = readCondaBinFile(executable); + resolve(); + }); + } catch (error) { + void vscode.window.showErrorMessage(`Error when activating conda environment: ${error as string}`); + reject(); + } + }); +} + +function readCondaBinFile(executable: VirtualRExecutableType) { + return fs.readFileSync(`${tmpDir()}/${executable.name}Env.txt`).toString().trim(); +} diff --git a/src/executables/virtual/index.ts b/src/executables/virtual/index.ts new file mode 100644 index 000000000..5a8d63f61 --- /dev/null +++ b/src/executables/virtual/index.ts @@ -0,0 +1,2 @@ +export * from './conda'; +export * from './renv'; \ No newline at end of file diff --git a/src/executables/virtual/renv.ts b/src/executables/virtual/renv.ts new file mode 100644 index 000000000..d64aaed29 --- /dev/null +++ b/src/executables/virtual/renv.ts @@ -0,0 +1,29 @@ +import path = require('path'); +import * as fs from 'fs-extra'; +import { IRenvJSONLock } from '../virtual/types'; + +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; + } +} diff --git a/src/executables/virtual/types.ts b/src/executables/virtual/types.ts new file mode 100644 index 000000000..5a5311a17 --- /dev/null +++ b/src/executables/virtual/types.ts @@ -0,0 +1,30 @@ + +interface LockPackage { + Package: string, + Version: string, + Source: string, + Repository: string, + Hash?: string +} + +interface LockRepository { + 'Name': string, + 'URL': string +} + +type LockPythonType = 'virtualenv' | 'conda' | 'system' + +export interface IRenvJSONLock { + R: { + Version: string, + Repositories: LockRepository[] + }, + Packages: { + [key: string]: LockPackage + }, + Python?: { + Version: string, + Type: LockPythonType + Name?: string + } +} diff --git a/src/extension.ts b/src/extension.ts index d15bf329d..86900fbc9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,7 @@ import * as rShare from './liveShare'; import * as httpgdViewer from './plotViewer'; import * as languageService from './languageService'; import { RTaskProvider } from './tasks'; +import * as rExec from './executables'; // global objects used in other files @@ -36,6 +37,7 @@ export let enableSessionWatcher: boolean | undefined = undefined; export let globalHttpgdManager: httpgdViewer.HttpgdManager | undefined = undefined; export let rmdPreviewManager: rmarkdown.RMarkdownPreviewManager | undefined = undefined; export let rmdKnitManager: rmarkdown.RMarkdownKnitManager | undefined = undefined; +export let rExecService: rExec.RExecutableManager | undefined = undefined; // Called (once) when the extension is activated export async function activate(context: vscode.ExtensionContext): Promise { @@ -51,6 +53,7 @@ export async function activate(context: vscode.ExtensionContext): Promise('sessionWatcher'); @@ -62,6 +65,7 @@ export async function activate(context: vscode.ExtensionContext): Promise rExecService?.executableQuickPick.showQuickPick(), // run code from editor in terminal 'r.nrow': () => rTerminal.runSelectionOrWord(['nrow']), @@ -70,7 +74,7 @@ export async function activate(context: vscode.ExtensionContext): Promise rTerminal.runSelectionOrWord(['t', 'head']), 'r.names': () => rTerminal.runSelectionOrWord(['names']), 'r.runSource': () => { void rTerminal.runSource(false); }, - 'r.runSelection': (code?: string) => { code ? void rTerminal.runTextInTerm(code) : void rTerminal.runSelection(); }, + 'r.runSelection': (code?: string) => { code ? void rTerminal.runTextInTerm(code) : void rTerminal.runSelection(); }, 'r.runFromLineToEnd': rTerminal.runFromLineToEnd, 'r.runFromBeginningToLine': rTerminal.runFromBeginningToLine, 'r.runSelectionRetainCursor': rTerminal.runSelectionRetainCursor, @@ -109,7 +113,7 @@ export async function activate(context: vscode.ExtensionContext): Promise rmarkdown.newDraft(), - 'r.newFileDocument': () => vscode.workspace.openTextDocument({language: 'r'}).then((v) => vscode.window.showTextDocument(v)), + 'r.newFileDocument': () => vscode.workspace.openTextDocument({ language: 'r' }).then((v) => vscode.window.showTextDocument(v)), // editor independent commands 'r.createGitignore': rGitignore.createGitignore, @@ -153,17 +157,18 @@ 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()); - } + if (rExecService.activeExecutable) { + activateServices(context, rExtension); } + // todo, this is a stopgap + rExecService?.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', { @@ -186,20 +191,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 httpgd viewer + globalHttpgdManager = httpgdViewer.initializeHttpgd(); + // initialize the package/help related functions + globalRHelp = rHelp.initializeHelp(context, rExtension); +} \ No newline at end of file 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/languageService.ts b/src/languageService.ts index 17a69a0b2..657de3102 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -53,7 +53,7 @@ export class LanguageService implements Disposable { let client: LanguageClient; const debug = config.get('lsp.debug'); - 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 ca8ad4966..425599885 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -6,6 +6,8 @@ import { isDeepStrictEqual } from 'util'; import * as vscode from 'vscode'; import { extensionContext, homeExtDir } from './extension'; +import { modifyEnvVars } from './executables'; +import { rExecService } from './extension'; import * as util from './util'; import * as selection from './selection'; import { getSelection } from './selection'; @@ -114,10 +116,10 @@ export async function runFromLineToEnd(): Promise { await runTextInTerm(text); } -export async function makeTerminalOptions(): Promise { - const termPath = await getRterm(); +export function makeTerminalOptions(): vscode.TerminalOptions { + const termPath = getRterm(); const shellArgs: string[] = config().get('rterm.option') || []; - const termOptions: vscode.TerminalOptions = { + let termOptions: vscode.TerminalOptions = { name: 'R Interactive', shellPath: termPath, shellArgs: shellArgs, @@ -132,11 +134,14 @@ export async function makeTerminalOptions(): Promise { VSCODE_WATCHER_DIR: homeExtDir() }; } + if (rExecService?.activeExecutable) { + termOptions = modifyEnvVars(termOptions, rExecService.activeExecutable); + } return termOptions; } -export async function createRTerm(preserveshow?: boolean): Promise { - const termOptions = await makeTerminalOptions(); +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.'); @@ -150,11 +155,11 @@ export async function createRTerm(preserveshow?: boolean): Promise { return true; } -export async function restartRTerminal(): Promise{ +export function restartRTerminal(): void { if (typeof rTerm !== 'undefined'){ rTerm.dispose(); deleteTerminal(rTerm); - await createRTerm(true); + createRTerm(true); } } @@ -223,7 +228,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..d30ff020e 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 = getRpath(); if (!rPath) { return undefined; } diff --git a/src/rmarkdown/knit.ts b/src/rmarkdown/knit.ts index 9225518c9..e0a1ca310 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 = util.getRpath(); const lim = '<<>>'; const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'gms'); diff --git a/src/rmarkdown/preview.ts b/src/rmarkdown/preview.ts index eb5efb79b..512d68b72 100644 --- a/src/rmarkdown/preview.ts +++ b/src/rmarkdown/preview.ts @@ -307,7 +307,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 fdb939139..ca1d55ef6 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -122,7 +122,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) { @@ -130,7 +130,7 @@ export class RTaskProvider implements vscode.TaskProvider { } const tasks: vscode.Task[] = []; - const rPath = await getRpath(false); + const rPath = getRpath(false); if (!rPath) { return []; } @@ -147,14 +147,15 @@ 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); + const rPath = getRpath(false); if (!rPath) { + 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); diff --git a/src/test/suite/executables.test.ts b/src/test/suite/executables.test.ts new file mode 100644 index 000000000..2bf0a2a83 --- /dev/null +++ b/src/test/suite/executables.test.ts @@ -0,0 +1,101 @@ +import vscode = require('vscode'); +import sinon = require('sinon'); +import path = require('path'); +import assert = require('assert'); + + +import * as ext from '../../extension'; +import * as exec from '../../executables/service'; +import { ExecutableStatusItem } from '../../executables/ui'; +import { mockExtensionContext } from '../common'; +import { RExecutablePathStorage } from '../../executables/service/pathStorage'; +import { DummyMemento } from '../../util'; +import { LocatorServiceFactory } from '../../executables/service/locator'; +import { VirtualRExecutable } from '../../executables/service'; + +const extension_root: string = path.join(__dirname, '..', '..', '..'); + +suite('R Executable Service', () => { + let sandbox: sinon.SinonSandbox; + setup(() => { + sandbox = sinon.createSandbox(); + }); + teardown(() => { + sandbox.restore(); + }); + + test('status item text', () => { + mockExtensionContext(extension_root, sandbox); + let executableValue: exec.RExecutableType | undefined = undefined; + const statusItem = new ExecutableStatusItem({ + get activeExecutable() { + return executableValue; + } + } as unknown as exec.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(); + }); + + test('status item loading indicator', async () => { + mockExtensionContext(extension_root, sandbox); + const dummyPromise: Promise = new Promise(() => { + // + }); + const statusItem = new ExecutableStatusItem({ + get activeExecutable() { + return undefined; + } + } as unknown as exec.RExecutableService); + + void statusItem.makeBusy(dummyPromise); + assert.strictEqual(statusItem.busy, true); + + await statusItem.makeBusy(Promise.resolve()); + assert.strictEqual(statusItem.busy, false); + assert.strictEqual(statusItem.severity, vscode.LanguageStatusSeverity.Warning); + }); + + 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'); + }); + + test('executable locator', async () => { + const locator = LocatorServiceFactory.getLocator(); + await locator.refreshPaths(); + assert(locator.executablePaths.length > 0); + }); +}); diff --git a/src/util.ts b/src/util.ts index ce4872b2e..7a3085999 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2,62 +2,17 @@ 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, rExecService } from './extension'; import { randomBytes } from 'crypto'; export function config(): vscode.WorkspaceConfiguration { return vscode.workspace.getConfiguration('r'); } -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 = ( @@ -68,27 +23,14 @@ 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); - - // read from path/registry: - rpath ||= await getRpathFromSystem(); - - // represent all invalid paths (undefined, '', null) as undefined: - rpath ||= undefined; + rpath = rExecService?.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}"`; @@ -103,17 +45,17 @@ export async function getRpath(quote = false, overwriteConfig?: string): Promise return rpath; } -export async function getRterm(): Promise { +export function getRterm(): string | undefined { const configEntry = getRPathConfigEntry(true); let rpath = config().get(configEntry); - rpath ||= await getRpathFromSystem(); + rpath ||= getRpath(); 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. Set executable path to R path.`); return undefined; } @@ -308,7 +250,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; } @@ -518,7 +460,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 = getRpath(); if (!rPath) { void vscode.window.showErrorMessage('R path not set', 'OK'); return; @@ -642,3 +584,16 @@ export function uniqueEntries(array: T[], isIdentical: (x: T, y: T) => boolea } return array.filter(uniqueFunction); } + +export function isMultiRoot(): boolean { + const folders = vscode?.workspace?.workspaceFolders; + if (folders) { + return folders.length > 1; + } else { + return false; + } +} + +export function normaliseRPathString(path: string): string { + return path.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'); +} \ No newline at end of file