Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
},
Expand Down
13 changes: 8 additions & 5 deletions src/languageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/rTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -115,14 +115,15 @@ export async function runFromLineToEnd(): Promise<void> {
}

export async function makeTerminalOptions(): Promise<vscode.TerminalOptions> {
const workspaceFolderPath = getCurrentWorkspaceFolder()?.uri.fsPath;
const workspaceFolder = getCurrentWorkspaceFolder();
const workingDirectory = resolveRWorkingDirectory(workspaceFolder?.uri);
const termPath = await getRterm();
const shellArgs: string[] = config().get<string[]>('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'));
Expand Down
182 changes: 182 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

/**
* 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<string>('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,
Expand Down