diff --git a/README.md b/README.md index a2a3671f..1c6ecdca 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Go to the installation wiki pages ([Windows](https://github.com/REditorSupport/v * [Interacting with R terminals](https://github.com/REditorSupport/vscode-R/wiki/Interacting-with-R-terminals): Sending code to terminals, running multiple terminals, working with remote servers. +* **Smart R working directory**: Automatically detects R project structures (renv, .Rproj, packages) or use explicit `r.workingDirectory` setting. + * [Package development](https://github.com/REditorSupport/vscode-R/wiki/Package-development): Build, test, install, load all and other commands from devtools. * [Keyboard shortcuts](https://github.com/REditorSupport/vscode-R/wiki/Keyboard-shortcuts): Built-in and customizable keyboard shortcuts. diff --git a/package-lock.json b/package-lock.json index f622c71e..b489db95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "mocha": "^11.1.0", "sinon": "^15.0.1", "ts-loader": "^9.3.1", - "typescript": "^4.7.2", + "typescript": "^4.9.5", "webpack": "^5.99.6", "webpack-cli": "^4.10.0" }, diff --git a/package.json b/package.json index 840963d4..f43ca08e 100644 --- a/package.json +++ b/package.json @@ -1349,6 +1349,12 @@ "default": false, "markdownDescription": "Use renv library paths to launch R background processes (R languageserver, help server, etc.)." }, + "r.workingDirectory": { + "type": "string", + "default": "", + "scope": "resource", + "markdownDescription": "Specifies the working directory for R processes (Language Server and terminals). If not set, defaults to the workspace root. Supports variables like `${workspaceFolder}`. Examples:\n- `${workspaceFolder}/src/r-project` for subdirectory R projects\n- `${workspaceFolder}/analysis/env` for renv environments in subdirectories\n- `${fileDirname}` to use the directory of the current file" + }, "r.lsp.enabled": { "type": "boolean", "default": true, @@ -2017,7 +2023,7 @@ "mocha": "^11.1.0", "sinon": "^15.0.1", "ts-loader": "^9.3.1", - "typescript": "^4.7.2", + "typescript": "^4.9.5", "webpack": "^5.99.6", "webpack-cli": "^4.10.0" }, diff --git a/src/languageService.ts b/src/languageService.ts index 02c45e2c..25fb7191 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, spawn, substituteVariables, resolveRWorkingDirectory } from './util'; import { extensionContext } from './extension'; import { CommonOptions } from 'child_process'; @@ -222,7 +222,8 @@ export class LanguageService implements Disposable { { scheme: 'file', language: 'r', pattern: pattern }, { scheme: 'file', language: 'rmd', pattern: pattern }, ]; - const client = await self.createClient(self.config, documentSelector, folder.uri.fsPath, folder, self.outputChannel); + const cwd = resolveRWorkingDirectory(folder.uri); + const client = await self.createClient(self.config, documentSelector, cwd, folder, self.outputChannel); self.clients.set(key, client); self.initSet.delete(key); } @@ -238,7 +239,8 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await self.createClient(self.config, documentSelector, os.homedir(), undefined, self.outputChannel); + const cwd = resolveRWorkingDirectory(); // No scope for untitled documents + const client = await self.createClient(self.config, documentSelector, cwd, undefined, self.outputChannel); self.clients.set(key, client); self.initSet.delete(key); } @@ -253,8 +255,9 @@ export class LanguageService implements Disposable { const documentSelector: DocumentFilter[] = [ { scheme: 'file', pattern: document.uri.fsPath }, ]; + const cwd = dirname(document.uri.fsPath); const client = await self.createClient(self.config, documentSelector, - dirname(document.uri.fsPath), undefined, self.outputChannel); + cwd, undefined, self.outputChannel); self.clients.set(key, client); self.initSet.delete(key); } @@ -317,7 +320,7 @@ export class LanguageService implements Disposable { ]; const workspaceFolder = workspace.workspaceFolders?.[0]; - const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); + const cwd = resolveRWorkingDirectory(workspaceFolder?.uri); self.client = await self.createClient(self.config, documentSelector, cwd, workspaceFolder, self.outputChannel); } } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index e527066a..3591e399 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -10,7 +10,7 @@ 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, getRterm, getCurrentWorkspaceFolder, resolveRWorkingDirectory } from './util'; import { rGuestService, isGuestSession } from './liveShare'; import * as fs from 'fs'; export let rTerm: vscode.Terminal | undefined = undefined; @@ -115,14 +115,15 @@ export async function runFromLineToEnd(): Promise { } export async function makeTerminalOptions(): Promise { - const workspaceFolderPath = getCurrentWorkspaceFolder()?.uri.fsPath; + const workspaceFolder = getCurrentWorkspaceFolder(); + const workingDirectory = resolveRWorkingDirectory(workspaceFolder?.uri); const termPath = await getRterm(); const shellArgs: string[] = config().get('rterm.option')?.map(util.substituteVariables) || []; const termOptions: vscode.TerminalOptions = { name: 'R Interactive', shellPath: termPath, shellArgs: shellArgs, - cwd: workspaceFolderPath, + cwd: workingDirectory, }; const newRprofile = extensionContext.asAbsolutePath(path.join('R', 'session', 'profile.R')); const initR = extensionContext.asAbsolutePath(path.join('R', 'session','init.R')); diff --git a/src/util.ts b/src/util.ts index b4e280af..1af535c0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -194,6 +194,188 @@ export function getCurrentWorkspaceFolder(): vscode.WorkspaceFolder | undefined return undefined; } +// Cache for detected R project directories to avoid repeated file system checks +const rProjectDirectoryCache = new Map(); + +/** + * Detects the most appropriate R working directory based on project structure indicators. + * Uses a priority hierarchy to select the best match: + * 1. renv.lock files (highest priority - indicates active renv environment) + * 2. .Rproj files (RStudio projects) + * 3. DESCRIPTION files (R packages) + * 4. Falls back to workspace root + * + * @param workspaceFolder - The workspace folder to scan + * @returns Detected R project directory path, or null if no indicators found + */ +function detectRProjectDirectory(workspaceFolder: vscode.WorkspaceFolder): string | null { + const workspacePath = workspaceFolder.uri.fsPath; + + // Check cache first + if (rProjectDirectoryCache.has(workspacePath)) { + return rProjectDirectoryCache.get(workspacePath) || null; + } + + let detectedPath: string | null = null; + + try { + // Priority 1: Look for renv.lock files with nearby R content + const renvLockPath = path.join(workspacePath, 'renv.lock'); + if (fs.existsSync(renvLockPath)) { + // Check if this directory or subdirectories have R files + const hasRContent = hasRFilesInDirectory(workspacePath); + if (hasRContent) { + detectedPath = workspacePath; + } + } + + // Priority 2: Look for .Rproj files if renv detection failed + if (!detectedPath) { + const rprojFiles = findFilesInDirectory(workspacePath, '.Rproj', 2); // Max depth 2 + if (rprojFiles.length > 0) { + // Use the directory containing the first .Rproj file found + detectedPath = path.dirname(rprojFiles[0]); + } + } + + // Priority 3: Look for DESCRIPTION files (R packages) + if (!detectedPath) { + const descriptionPath = path.join(workspacePath, 'DESCRIPTION'); + if (fs.existsSync(descriptionPath)) { + // Verify it's an R package by checking for Package: field + try { + const content = fs.readFileSync(descriptionPath, 'utf-8'); + if (content.includes('Package:')) { + detectedPath = workspacePath; + } + } catch (e) { + // Ignore read errors + } + } + } + + } catch (e) { + // Log but don't throw - graceful degradation + console.warn('R project detection failed:', e); + } + + // Cache the result (including null) + rProjectDirectoryCache.set(workspacePath, detectedPath || ''); + + return detectedPath; +} + +/** + * Checks if a directory contains R files (.R, .r, .Rmd, .rmd files) + */ +function hasRFilesInDirectory(dirPath: string, maxDepth: number = 2): boolean { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (['.r', '.rmd'].includes(ext)) { + return true; + } + } else if (entry.isDirectory() && maxDepth > 0) { + const subDirPath = path.join(dirPath, entry.name); + if (hasRFilesInDirectory(subDirPath, maxDepth - 1)) { + return true; + } + } + } + } catch (e) { + // Ignore permission errors, etc. + } + + return false; +} + +/** + * Finds files with specific extension in directory tree + */ +function findFilesInDirectory(dirPath: string, extension: string, maxDepth: number): string[] { + const results: string[] = []; + + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isFile() && entry.name.endsWith(extension)) { + results.push(fullPath); + } else if (entry.isDirectory() && maxDepth > 0) { + results.push(...findFilesInDirectory(fullPath, extension, maxDepth - 1)); + } + } + } catch (e) { + // Ignore permission errors, etc. + } + + return results; +} + +/** + * Resolves the working directory for R processes based on configuration and smart defaults. + * Priority order: + * 1. Explicit r.workingDirectory setting (if configured and valid) + * 2. Auto-detected R project directory (renv.lock, .Rproj, DESCRIPTION) + * 3. Workspace root (fallback) + * + * @param scopeUri - Optional URI to determine the configuration scope (for multi-root workspaces) + * @returns Resolved absolute path to use as working directory for R processes + */ +export function resolveRWorkingDirectory(scopeUri?: vscode.Uri): string { + const config = vscode.workspace.getConfiguration('r', scopeUri); + const workingDirectorySetting = config.get('workingDirectory'); + + // Priority 1: Use explicit configuration if provided + if (workingDirectorySetting && workingDirectorySetting.trim() !== '') { + // Apply variable substitution to the configured working directory + const resolvedPath = substituteVariables(workingDirectorySetting); + + // Validate that the resolved path exists and is within workspace boundaries + if (fs.existsSync(resolvedPath)) { + // Get the workspace folder for security validation + const workspaceFolder = scopeUri + ? vscode.workspace.getWorkspaceFolder(scopeUri) + : getCurrentWorkspaceFolder(); + + if (workspaceFolder) { + const workspaceRoot = workspaceFolder.uri.fsPath; + const relativePath = path.relative(workspaceRoot, resolvedPath); + + // Ensure the resolved path is within the workspace boundaries + if (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) { + return path.resolve(resolvedPath); + } else { + console.warn(`R working directory "${resolvedPath}" is outside workspace bounds. Using smart defaults.`); + } + } + } else { + console.warn(`R working directory "${resolvedPath}" does not exist. Using smart defaults.`); + } + } + + // Priority 2: Auto-detect R project directory when no valid explicit configuration + const workspaceFolder = scopeUri + ? vscode.workspace.getWorkspaceFolder(scopeUri) + : getCurrentWorkspaceFolder(); + + if (workspaceFolder) { + const detectedPath = detectRProjectDirectory(workspaceFolder); + if (detectedPath && fs.existsSync(detectedPath)) { + console.log(`Auto-detected R project directory: ${detectedPath}`); + return path.resolve(detectedPath); + } + } + + // Priority 3: Fall back to workspace root + return workspaceFolder ? workspaceFolder.uri.fsPath : homedir(); +} + // Drop-in replacement for fs-extra.readFile (), // passes to guest service if the caller is a guest // This can be used wherever fs.readFile() is used,