Skip to content
Open
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
45 changes: 37 additions & 8 deletions .vscode-test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
const { defineConfig } = require("@vscode/test-cli");

module.exports = defineConfig({
files: "out/test/**/*.test.js",
workspaceFolder: "./src/testFixture",
mocha: {
ui: "tdd",
color: true,
timeout: 20000,
module.exports = defineConfig([
{
label: "E2E tests",
files: "out/test/e2e.test.js",
workspaceFolder: "./src/testFixture",
mocha: {
ui: "tdd",
color: true,
timeout: 20000,
},
},
});
{
label: "Unit tests",
files: "out/test/utils.test.js",
workspaceFolder: "./src/testFixture",
mocha: {
ui: "tdd",
color: true,
timeout: 20000,
},
},
{
label: "VFS tests",
files: "out/test/vfs.e2e.test.js",
extensionDevelopmentPath: [
".",
"./src/test/testVfsExtension",
],
launchArgs: [
"--folder-uri", "testvfs:/project",
],
mocha: {
ui: "tdd",
color: true,
timeout: 60000,
},
},
]);
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
]
},
"virtualWorkspaces": {
"supported": false,
"description": "Virtual Workspaces are not supported by the Ruff extension."
"supported": "limited",
"description": "Virtual Workspaces are supported when a VFS provider registers a URI translator via the extension API."
}
},
"activationEvents": [
Expand Down
6 changes: 3 additions & 3 deletions src/common/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async function executeCommand(lsClient: LanguageClient, command: string) {
}

const textDocument = {
uri: textEditor.document.uri.toString(),
uri: lsClient.code2ProtocolConverter.asUri(textEditor.document.uri),
version: textEditor.document.version,
};
const params = {
Expand Down Expand Up @@ -74,9 +74,9 @@ export function createDebugInformationProvider(
arguments: [
{
textDocument: notebookEditor
? { uri: notebookEditor.notebook.uri.toString() }
? { uri: lsClient.code2ProtocolConverter.asUri(notebookEditor.notebook.uri) }
: textEditor
? { uri: textEditor.document.uri.toString() }
? { uri: lsClient.code2ProtocolConverter.asUri(textEditor.document.uri) }
: undefined,
},
],
Expand Down
37 changes: 35 additions & 2 deletions src/common/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import {
RevealOutputChannelOn,
ServerOptions,
} from "vscode-languageclient/node";
import { isVirtualWorkspace } from "./vscodeapi";
import { getRegisteredTranslator } from "./uriTranslator";
import {
initializeTranslator,
configureVfsClientOptions,
translateInitializationSettings,
} from "./vfsSupport";
import {
BUNDLED_RUFF_EXECUTABLE,
DEBUG_SERVER_SCRIPT_PATH,
Expand Down Expand Up @@ -106,6 +113,13 @@ async function findRuffBinaryPath(settings: ISettings): Promise<string> {
return BUNDLED_RUFF_EXECUTABLE;
}

// In virtual workspaces, use the bundled executable since user-configured
// paths are not accessible on a virtual file system.
if (isVirtualWorkspace()) {
logger.info(`Using bundled executable for virtual workspace: ${BUNDLED_RUFF_EXECUTABLE}`);
return BUNDLED_RUFF_EXECUTABLE;
}

// 'path' setting takes priority over everything.
if (settings.path.length > 0) {
for (const path of settings.path) {
Expand Down Expand Up @@ -217,7 +231,7 @@ async function createNativeServer(
options: { cwd: settings.cwd, env: process.env },
};

const clientOptions = {
const clientOptions: LanguageClientOptions = {
// Register the server for python documents
documentSelector: getDocumentSelector(),
outputChannel,
Expand All @@ -226,7 +240,22 @@ async function createNativeServer(
initializationOptions,
};

return new LanguageClient(serverId, serverName, serverOptions, clientOptions);
// If a VFS translator is registered, initialize it and configure the
// LanguageClient for URI translation between virtual and local paths.
const translator = getRegisteredTranslator();
if (translator) {
const translatedCwd = await initializeTranslator();
serverOptions.options.cwd = translatedCwd ?? process.cwd();
configureVfsClientOptions(clientOptions, translator, () => _vfsClient);
}

const client = new LanguageClient(serverId, serverName, serverOptions, clientOptions);

if (translator) {
_vfsClient = client;
}

return client;
}

async function createLegacyServer(
Expand Down Expand Up @@ -443,6 +472,7 @@ async function createServer(
}

let _disposables: Disposable[] = [];
let _vfsClient: LanguageClient | undefined;

export async function startServer(
projectRoot: vscode.WorkspaceFolder,
Expand All @@ -461,6 +491,9 @@ export async function startServer(
const globalSettings = await getGlobalSettings(serverId);
logger.info(`Global settings: ${JSON.stringify(globalSettings, null, 4)}`);

// Translate virtual workspace paths in settings before sending to the server.
translateInitializationSettings(workspaceSettings, extensionSettings, globalSettings);

const newLSClient = await createServer(
workspaceSettings,
projectRoot,
Expand Down
22 changes: 19 additions & 3 deletions src/common/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,22 @@ function resolveVariables(
substitutions.set("${userHome}", home);
}
if (workspace) {
substitutions.set("${workspaceFolder}", workspace.uri.fsPath);
// For virtual workspaces, fsPath may not be meaningful, but we still
// set it for variable substitution — the VirtualFileCache will translate
// actual URIs. If the scheme is not 'file', use the URI path as-is.
if (workspace.uri.scheme === "file") {
substitutions.set("${workspaceFolder}", workspace.uri.fsPath);
} else {
substitutions.set("${workspaceFolder}", workspace.uri.path);
}
}
substitutions.set("${cwd}", process.cwd());
getWorkspaceFolders().forEach((w) => {
substitutions.set("${workspaceFolder:" + w.name + "}", w.uri.fsPath);
if (w.uri.scheme === "file") {
substitutions.set("${workspaceFolder:" + w.name + "}", w.uri.fsPath);
} else {
substitutions.set("${workspaceFolder:" + w.name + "}", w.uri.path);
}
});
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
Expand Down Expand Up @@ -149,9 +160,14 @@ export async function getWorkspaceSettings(
configuration = resolveVariables(configuration, workspace);
}

// For virtual workspaces, use the URI path instead of fsPath.
// The VirtualFileCache will later override cwd with the local cache root.
const workspaceCwd =
workspace.uri.scheme === "file" ? workspace.uri.fsPath : workspace.uri.path;

return {
nativeServer: config.get<NativeServer>("nativeServer") ?? "auto",
cwd: workspace.uri.fsPath,
cwd: workspaceCwd,
workspace: workspace.uri.toString(),
path: resolveVariables(config.get<string[]>("path") ?? [], workspace),
ignoreStandardLibrary: config.get<boolean>("ignoreStandardLibrary") ?? true,
Expand Down
110 changes: 110 additions & 0 deletions src/common/uriTranslator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as vscode from "vscode";
import { logger } from "./logger";

/**
* Interface for URI translators provided by VFS extensions (e.g., vscode-trident).
*
* VFS providers implement this interface and register it with the Ruff extension
* via `ruffExtension.exports.registerUriTranslator(translator)`.
*
* This follows the same pattern as Pylance's `registerUriTranslator` API.
*/
export interface RuffUriTranslator {
/**
* Called once before the Ruff language server starts. Use this to prepare
* the local disk cache — e.g., sync config files, create directory structures,
* warm caches, and pre-populate all URI translations.
*
* All async work (file I/O, network requests) must happen here.
* After initialize() resolves, translateToDisk/translateToVirtual must work synchronously.
*
* @param workspaceFolders The workspace folder URIs that the server will operate on
*/
initialize(workspaceFolders: vscode.Uri[]): Promise<void>;

/**
* Translates a URI from a virtual workspace location to a disk cache location.
* Called synchronously for inbound requests/notifications to the Ruff language server.
* Must return immediately — all caching should be done in initialize().
* @param uri The virtual workspace URI to translate
* @returns The translated disk cache URI, or undefined if no translation is needed
*/
translateToDisk(uri: vscode.Uri): vscode.Uri | undefined;

/**
* Translates a URI from a disk cache location back to a virtual workspace location.
* Called synchronously for outbound responses from the Ruff language server.
* Must return immediately.
* @param uri The disk cache URI to translate
* @returns The translated virtual workspace URI, or undefined if no translation is needed
*/
translateToVirtual(uri: vscode.Uri): vscode.Uri | undefined;
}

/**
* Public API exported by the Ruff extension for consumption by VFS providers.
*
* Usage from a VFS extension:
* ```typescript
* const ruffExtension = vscode.extensions.getExtension('charliermarsh.ruff');
* if (ruffExtension?.isActive && ruffExtension.exports?.registerUriTranslator) {
* ruffExtension.exports.registerUriTranslator(myTranslator);
* }
* ```
*/
export interface RuffExtensionApi {
/**
* Register a URI translator for virtual workspace support.
* When registered, the Ruff language server will be restarted to use the
* translator for all URI conversions between VS Code and the server.
*
* Only one translator can be registered at a time. Registering a new
* translator replaces the previous one and triggers a server restart.
*/
registerUriTranslator(translator: RuffUriTranslator): void;
}

// Module-level state for the registered translator
let registeredTranslator: RuffUriTranslator | undefined;
let onTranslatorRegistered: (() => Promise<void>) | undefined;

/**
* Get the currently registered URI translator, if any.
*/
export function getRegisteredTranslator(): RuffUriTranslator | undefined {
return registeredTranslator;
}

/**
* Create the extension API object that will be returned from `activate()`.
* @param restartCallback Called when a translator is registered to restart the server
*/
export function createExtensionApi(
restartCallback: () => Promise<void>,
): RuffExtensionApi {
onTranslatorRegistered = restartCallback;

return {
registerUriTranslator(translator: RuffUriTranslator): void {
logger.info("[UriTranslator] URI translator registered by external VFS provider");
registeredTranslator = translator;

// Restart the language server so it picks up the translator
if (onTranslatorRegistered) {
onTranslatorRegistered().then(() => {
}).catch((err) => {
logger.error(`[UriTranslator] Failed to restart server after translator registration: ${err}`);
});
} else {
}
},
};
}

/**
* Clear the registered translator (for cleanup on deactivation).
*/
export function clearRegisteredTranslator(): void {
registeredTranslator = undefined;
onTranslatorRegistered = undefined;
}
3 changes: 3 additions & 0 deletions src/common/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export async function getProjectRoot(): Promise<WorkspaceFolder> {
};
} else if (workspaces.length === 1) {
return workspaces[0];
} else if (isVirtualWorkspace()) {
// In virtual workspaces, return the first folder without filesystem checks
return workspaces[0];
} else {
let rootWorkspace = workspaces[0];
let root = undefined;
Expand Down
Loading