diff --git a/.vscode-test.js b/.vscode-test.js index 8d66ddd1..29fca9c6 100644 --- a/.vscode-test.js +++ b/.vscode-test.js @@ -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, + }, + }, +]); diff --git a/package.json b/package.json index be0dae3a..96dd9f16 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/common/commands.ts b/src/common/commands.ts index 7e8ed83c..dce5aea0 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -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 = { @@ -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, }, ], diff --git a/src/common/server.ts b/src/common/server.ts index 702c75a0..58801b31 100644 --- a/src/common/server.ts +++ b/src/common/server.ts @@ -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, @@ -106,6 +113,13 @@ async function findRuffBinaryPath(settings: ISettings): Promise { 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) { @@ -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, @@ -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( @@ -443,6 +472,7 @@ async function createServer( } let _disposables: Disposable[] = []; +let _vfsClient: LanguageClient | undefined; export async function startServer( projectRoot: vscode.WorkspaceFolder, @@ -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, diff --git a/src/common/settings.ts b/src/common/settings.ts index ae986966..1768520d 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -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) { @@ -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") ?? "auto", - cwd: workspace.uri.fsPath, + cwd: workspaceCwd, workspace: workspace.uri.toString(), path: resolveVariables(config.get("path") ?? [], workspace), ignoreStandardLibrary: config.get("ignoreStandardLibrary") ?? true, diff --git a/src/common/uriTranslator.ts b/src/common/uriTranslator.ts new file mode 100644 index 00000000..ac1b5dcc --- /dev/null +++ b/src/common/uriTranslator.ts @@ -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; + + /** + * 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) | 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, +): 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; +} diff --git a/src/common/utilities.ts b/src/common/utilities.ts index 43ca2924..0d13ac4e 100644 --- a/src/common/utilities.ts +++ b/src/common/utilities.ts @@ -14,6 +14,9 @@ export async function getProjectRoot(): Promise { }; } 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; diff --git a/src/common/vfsSupport.ts b/src/common/vfsSupport.ts new file mode 100644 index 00000000..42eefbe0 --- /dev/null +++ b/src/common/vfsSupport.ts @@ -0,0 +1,188 @@ +/** + * Virtual filesystem (VFS) support for the Ruff language server. + * + * When a URI translator is registered by a VFS provider, this module handles: + * - Initializing the translator and translating the server's working directory + * - Configuring LanguageClient uriConverters for bidirectional URI translation + * - Setting up middleware for config file change notifications + * - Translating cwd/workspace in settings sent to the server + */ +import * as vscode from "vscode"; +import { LanguageClientOptions } from "vscode-languageclient/node"; +import { isVirtualWorkspace } from "./vscodeapi"; +import { getRegisteredTranslator, RuffUriTranslator } from "./uriTranslator"; +import { ISettings } from "./settings"; +import { logger } from "./logger"; + +/** + * Initialize the VFS translator and configure server options. + * Called from createNativeServer() when a translator is registered. + * + * @returns The translated cwd path, or undefined if no translator/translation available + */ +export async function initializeTranslator(): Promise { + const translator = getRegisteredTranslator(); + if (!translator) { + return undefined; + } + + const workspaceFolderUris = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri); + try { + await translator.initialize(workspaceFolderUris); + logger.info("[UriTranslator] Translator initialization completed"); + } catch (err) { + logger.error(`[UriTranslator] Translator initialization failed: ${err}`); + } + + // Return the translated workspace root for use as server cwd + if (workspaceFolderUris.length > 0) { + const translatedRoot = translator.translateToDisk(workspaceFolderUris[0]); + if (translatedRoot) { + return translatedRoot.fsPath; + } + } + return undefined; +} + +/** + * Configure LanguageClient options for VFS URI translation. + * Adds uriConverters and middleware to the client options. + * + * @param clientOptions The LanguageClientOptions to configure + * @param translator The registered URI translator + * @param getVfsClient Function to get the current VFS LanguageClient (for sending notifications) + */ +export function configureVfsClientOptions( + clientOptions: LanguageClientOptions, + translator: RuffUriTranslator, + getVfsClient: () => import("vscode-languageclient/node").LanguageClient | undefined, +): void { + // All translate calls are synchronous — use directly in uriConverters + clientOptions.uriConverters = { + code2Protocol: (uri: vscode.Uri): string => { + const translated = translator.translateToDisk(uri); + if (translated) { + // Use skipEncoding to avoid percent-encoding the colon in drive letters + // (e.g., file:///c:/... instead of file:///c%3A/...) + return translated.toString(true); + } + return uri.toString(); + }, + protocol2Code: (uriStr: string): vscode.Uri => { + const uri = vscode.Uri.parse(uriStr); + const translated = translator.translateToVirtual(uri); + return translated ?? uri; + }, + }; + + // Middleware for config file change notifications and settings translation + const CONFIG_FILES = ["pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg"]; + + const notifyConfigChange = (uri: vscode.Uri) => { + const name = uri.path.split("/").pop() ?? ""; + const translated = translator.translateToDisk(uri); + const client = getVfsClient(); + if (CONFIG_FILES.includes(name) && translated && client) { + client.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri: translated.toString(), type: 2 /* Changed */ }], + }); + } + }; + + clientOptions.middleware = { + didOpen: (document, next) => { + return next(document); + }, + didChange: (event, next) => { + notifyConfigChange(event.document.uri); + return next(event); + }, + didSave: (document, next) => { + notifyConfigChange(document.uri); + return next(document); + }, + workspace: { + configuration: async (params, token, next) => { + const result = await next(params, token); + // Translate cwd/workspace in dynamically requested settings + if (Array.isArray(result)) { + for (const item of result) { + if (item && typeof item === "object") { + translateSettingsObject(item as Record, translator); + } + } + } + return result; + }, + }, + }; + + logger.info("[UriTranslator] External VFS translator configured on LanguageClient"); +} + +/** + * Translate cwd/workspace fields in settings objects sent to the server. + * Called from startServer() to fix initializationOptions, and from + * workspace/configuration middleware to fix dynamically requested settings. + */ +export function translateSettingsObject( + settings: Record, + translator: RuffUriTranslator, +): void { + if (typeof settings.cwd === "string" && typeof settings.workspace === "string") { + const wsUri = vscode.Uri.parse(settings.workspace as string); + const translated = translator.translateToDisk(wsUri); + if (translated) { + settings.cwd = translated.fsPath; + settings.workspace = translated.toString(true); + } + } +} + +/** + * Translate cwd/workspace in all settings objects before sending to the server. + * Called from startServer() to fix initializationOptions. + */ +export function translateInitializationSettings( + workspaceSettings: ISettings, + extensionSettings: ISettings[], + globalSettings: ISettings, +): void { + const translator = getRegisteredTranslator(); + if (!translator || !isVirtualWorkspace()) { + return; + } + + const folders = vscode.workspace.workspaceFolders ?? []; + for (const folder of folders) { + const translated = translator.translateToDisk(folder.uri); + if (!translated) { + continue; + } + const translatedCwd = translated.fsPath; + const translatedWorkspace = translated.toString(true); + + // Fix workspaceSettings if it matches this folder + if (workspaceSettings.workspace === folder.uri.toString()) { + workspaceSettings.cwd = translatedCwd; + workspaceSettings.workspace = translatedWorkspace; + } + + // Fix matching extensionSettings entries + for (const settings of extensionSettings) { + if (settings.workspace === folder.uri.toString()) { + settings.cwd = translatedCwd; + settings.workspace = translatedWorkspace; + } + } + } + + // Fix globalSettings — use first translated folder + if (folders.length > 0) { + const translated = translator.translateToDisk(folders[0].uri); + if (translated) { + globalSettings.cwd = translated.fsPath; + globalSettings.workspace = translated.toString(true); + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 01668cfc..ef862b30 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { loadServerDefaults } from "./common/setup"; import { registerLanguageStatusItem, updateStatus } from "./common/status"; import { getConfiguration, + isVirtualWorkspace, onDidChangeConfiguration, onDidGrantWorkspaceTrust, registerCommand, @@ -30,6 +31,12 @@ import { executeOrganizeImports, createDebugInformationProvider, } from "./common/commands"; +import { + createExtensionApi, + clearRegisteredTranslator, + getRegisteredTranslator, + RuffExtensionApi, +} from "./common/uriTranslator"; let lsClient: LanguageClient | undefined; let restartInProgress = false; @@ -39,7 +46,7 @@ function getClient(): LanguageClient | undefined { return lsClient; } -export async function activate(context: vscode.ExtensionContext): Promise { +export async function activate(context: vscode.ExtensionContext): Promise { // This is required to get server name and module. This should be // the first thing that we do in this extension. const serverInfo = loadServerDefaults(); @@ -70,12 +77,25 @@ export async function activate(context: vscode.ExtensionContext): Promise }), ); + // Create the extension API early so all code paths can return it. + // runServer is defined below but the API closure captures the reference. + // eslint-disable-next-line prefer-const + let runServer: () => Promise; + const extensionApi = createExtensionApi(async () => { + logger.info("[UriTranslator] VFS translator registered, restarting server..."); + try { + await runServer(); + } catch (err) { + logger.error(`[UriTranslator] runServer() failed: ${err}`); + } + }); + const { enable } = getConfiguration(serverId) as unknown as ISettings; if (!enable) { logger.info( "Extension is disabled. To enable, change `ruff.enable` to `true` and restart VS Code.", ); - return; + return extensionApi; } if (restartInProgress) { @@ -86,10 +106,15 @@ export async function activate(context: vscode.ExtensionContext): Promise ); restartQueued = true; } - return; + return extensionApi; } - const runServer = async () => { + runServer = async () => { + // In virtual workspaces, don't start until a translator is registered. + if (isVirtualWorkspace() && !getRegisteredTranslator()) { + return; + } + if (restartInProgress) { if (!restartQueued) { // Schedule a new restart after the current restart. @@ -111,7 +136,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const projectRoot = await getProjectRoot(); const workspaceSettings = await getWorkspaceSettings(serverId, projectRoot); - if (vscode.workspace.isTrusted) { + if (vscode.workspace.isTrusted && !isVirtualWorkspace()) { if (workspaceSettings.interpreter.length === 0) { updateStatus( vscode.l10n.t("Please select a Python interpreter."), @@ -207,6 +232,16 @@ export async function activate(context: vscode.ExtensionContext): Promise checkNotebookCodeActionsOnSave(serverId); setImmediate(async () => { + if (isVirtualWorkspace()) { + // In virtual workspaces, don't start the server immediately. + // Wait for a VFS provider to register a URI translator via the API. + // The registration callback will trigger runServer() automatically. + logger.info( + "[VFS] Virtual workspace detected. Waiting for a VFS provider to register a URI translator...", + ); + return; + } + if (vscode.workspace.isTrusted) { const interpreter = getInterpreterFromSetting(serverId); if (interpreter === undefined || interpreter.length === 0) { @@ -218,10 +253,13 @@ export async function activate(context: vscode.ExtensionContext): Promise } await runServer(); }); + + return extensionApi; } export async function deactivate(): Promise { if (lsClient) { await stopServer(lsClient); } + clearRegisteredTranslator(); } diff --git a/src/test/testVfsExtension/package.json b/src/test/testVfsExtension/package.json new file mode 100644 index 00000000..a97981ba --- /dev/null +++ b/src/test/testVfsExtension/package.json @@ -0,0 +1,16 @@ +{ + "name": "ruff-testvfs", + "displayName": "Ruff TestVFS - VFS provider and URI translator for tests", + "description": "Test file system provider and URI translator for testing Ruff virtual workspace support.", + "version": "0.0.1", + "publisher": "ruff", + "private": true, + "license": "MIT", + "engines": { + "vscode": "^1.85.0" + }, + "activationEvents": [ + "onFileSystem:testvfs" + ], + "main": "../../../out/test/testVfsExtension/src/extension" +} diff --git a/src/test/testVfsExtension/src/extension.ts b/src/test/testVfsExtension/src/extension.ts new file mode 100644 index 00000000..30944356 --- /dev/null +++ b/src/test/testVfsExtension/src/extension.ts @@ -0,0 +1,270 @@ +"use strict"; + +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +import type { RuffUriTranslator, RuffExtensionApi } from "../../../common/uriTranslator"; + +// --- In-memory virtual file store --- + +const VIRTUAL_FILES: Record = {}; + +// --- FileSystemProvider --- + +class TestFileSystemProvider implements vscode.FileSystemProvider { + private _emitter = new vscode.EventEmitter(); + readonly onDidChangeFile = this._emitter.event; + readonly scheme = "testvfs"; + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + stat(uri: vscode.Uri): vscode.FileStat { + const p = uri.path; + if (VIRTUAL_FILES[p] !== undefined) { + return { + type: vscode.FileType.File, + ctime: 0, + mtime: Date.now(), + size: Buffer.byteLength(VIRTUAL_FILES[p], "utf-8"), + }; + } + if (Object.keys(VIRTUAL_FILES).some((k) => k.startsWith(p + "/")) || p === "/") { + return { type: vscode.FileType.Directory, ctime: 0, mtime: Date.now(), size: 0 }; + } + throw vscode.FileSystemError.FileNotFound(uri); + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + const prefix = uri.path.endsWith("/") ? uri.path : uri.path + "/"; + const entries = new Map(); + for (const key of Object.keys(VIRTUAL_FILES)) { + if (key.startsWith(prefix)) { + const rest = key.substring(prefix.length); + const parts = rest.split("/"); + entries.set( + parts[0], + parts.length === 1 ? vscode.FileType.File : vscode.FileType.Directory, + ); + } + } + return Array.from(entries.entries()); + } + + readFile(uri: vscode.Uri): Uint8Array { + const content = VIRTUAL_FILES[uri.path]; + if (content !== undefined) { + return Buffer.from(content, "utf-8"); + } + throw vscode.FileSystemError.FileNotFound(uri); + } + + writeFile(uri: vscode.Uri, content: Uint8Array): void { + VIRTUAL_FILES[uri.path] = Buffer.from(content).toString("utf-8"); + this._emitter.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + + createDirectory(uri: vscode.Uri): void { + const p = uri.path.endsWith("/") ? uri.path : uri.path + "/"; + if (!Object.keys(VIRTUAL_FILES).some((k) => k.startsWith(p))) { + VIRTUAL_FILES[uri.path + "/.keep"] = ""; + } + } + + delete(uri: vscode.Uri): void { + delete VIRTUAL_FILES[uri.path]; + } + + rename(oldUri: vscode.Uri, newUri: vscode.Uri): void { + const content = VIRTUAL_FILES[oldUri.path]; + if (content !== undefined) { + VIRTUAL_FILES[newUri.path] = content; + delete VIRTUAL_FILES[oldUri.path]; + } + } + + clear(): void { + for (const key of Object.keys(VIRTUAL_FILES)) { + delete VIRTUAL_FILES[key]; + } + } +} + +// --- URI Translator --- + +export interface TranslatorStats { + translateToDiskCalls: number; + translateToVirtualCalls: number; + toDiskTranslated: number; + toVirtualTranslated: number; +} + +class TestUriTranslator implements RuffUriTranslator { + private static instance: TestUriTranslator | undefined; + + private cacheRoot: string; + private toDiskCache = new Map(); + private toVirtualCache = new Map(); + private stats: TranslatorStats = { + translateToDiskCalls: 0, + translateToVirtualCalls: 0, + toDiskTranslated: 0, + toVirtualTranslated: 0, + }; + + private constructor() { + this.cacheRoot = path.join(os.tmpdir(), `ruff-test-vfs-${process.pid}`); + fs.mkdirSync(this.cacheRoot, { recursive: true }); + } + + static getInstance(): TestUriTranslator { + if (!TestUriTranslator.instance) { + TestUriTranslator.instance = new TestUriTranslator(); + } + return TestUriTranslator.instance; + } + + async initialize(workspaceFolders: vscode.Uri[]): Promise { + for (const folderUri of workspaceFolders) { + const localRoot = this.buildLocalPath(folderUri); + fs.mkdirSync(localRoot, { recursive: true }); + this.toDiskCache.set(folderUri.toString(), vscode.Uri.file(localRoot)); + this.toVirtualCache.set(localRoot, folderUri); + + try { + const entries = await vscode.workspace.fs.readDirectory(folderUri); + for (const [name, type] of entries) { + if (type === vscode.FileType.File) { + const fileUri = folderUri.with({ path: path.posix.join(folderUri.path, name) }); + try { + const content = await vscode.workspace.fs.readFile(fileUri); + const localPath = this.buildLocalPath(fileUri); + fs.mkdirSync(path.dirname(localPath), { recursive: true }); + fs.writeFileSync(localPath, Buffer.from(content)); + this.toDiskCache.set(fileUri.toString(), vscode.Uri.file(localPath)); + this.toVirtualCache.set(localPath, fileUri); + } catch { /* skip */ } + } + } + } catch { /* skip */ } + } + } + + translateToDisk(uri: vscode.Uri): vscode.Uri | undefined { + this.stats.translateToDiskCalls++; + if (uri.scheme !== "testvfs") { + return undefined; + } + const cached = this.toDiskCache.get(uri.toString()); + if (cached) { + this.stats.toDiskTranslated++; + return cached; + } + const localPath = this.buildLocalPath(uri); + const diskUri = vscode.Uri.file(localPath); + this.toDiskCache.set(uri.toString(), diskUri); + this.toVirtualCache.set(localPath, uri); + this.stats.toDiskTranslated++; + return diskUri; + } + + translateToVirtual(uri: vscode.Uri): vscode.Uri | undefined { + this.stats.translateToVirtualCalls++; + if (uri.scheme !== "file") { + return undefined; + } + const direct = this.toVirtualCache.get(uri.fsPath); + if (direct) { + this.stats.toVirtualTranslated++; + return direct; + } + const normalized = uri.fsPath.toLowerCase(); + for (const [localPath, virtualUri] of this.toVirtualCache) { + if (localPath.toLowerCase() === normalized) { + this.stats.toVirtualTranslated++; + return virtualUri; + } + } + return undefined; + } + + private buildLocalPath(uri: vscode.Uri): string { + const sanitized = uri.path.replace(/^\/+/, "").replace(/[<>:"|?*]/g, "_"); + return path.join(this.cacheRoot, sanitized); + } + + getStats(): TranslatorStats { return { ...this.stats }; } + resetStats(): void { + this.stats = { translateToDiskCalls: 0, translateToVirtualCalls: 0, toDiskTranslated: 0, toVirtualTranslated: 0 }; + } +} + +// --- Exported types --- + +export interface ITestVfsExtension { + scheme: string; + createDirectory(uri: vscode.Uri): void; + writeFile(uri: vscode.Uri, content: Uint8Array): void; + clear(): void; + getStats(): TranslatorStats; + resetStats(): void; + translateToDisk(uri: vscode.Uri): vscode.Uri | undefined; + translateToVirtual(uri: vscode.Uri): vscode.Uri | undefined; +} + +// --- Extension activation --- + +export async function activate(context: vscode.ExtensionContext): Promise { + // Register VFS provider + const testFS = new TestFileSystemProvider(); + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(testFS.scheme, testFS, { isCaseSensitive: true }), + ); + + // Seed test files so they exist before Ruff starts + testFS.writeFile( + vscode.Uri.parse("testvfs:/project/diagnostics.py"), + Buffer.from( + "import os\nimport sys\nimport os\n\ndef greet(name):\n" + + ' print(f"Hello, {name}!")\n\ngreet("world")\n', + ), + ); + testFS.writeFile( + vscode.Uri.parse("testvfs:/project/formatting.py"), + Buffer.from( + "def function(foo,bar,):\n print('hello world')\n", + ), + ); + testFS.writeFile( + vscode.Uri.parse("testvfs:/project/pyproject.toml"), + Buffer.from( + '[tool.ruff.lint]\nselect = ["E", "F", "I"]\n', + ), + ); + + // Register URI translator with Ruff + const translator = TestUriTranslator.getInstance(); + const ruffExtension = vscode.extensions.getExtension("charliermarsh.ruff"); + if (ruffExtension) { + const ruffApi: RuffExtensionApi = ruffExtension.isActive + ? ruffExtension.exports + : await ruffExtension.activate(); + if (ruffApi.registerUriTranslator) { + ruffApi.registerUriTranslator(translator); + } + } + + return { + scheme: testFS.scheme, + createDirectory: (uri) => testFS.createDirectory(uri), + writeFile: (uri, content) => testFS.writeFile(uri, content), + clear: () => testFS.clear(), + getStats: () => translator.getStats(), + resetStats: () => translator.resetStats(), + translateToDisk: (uri) => translator.translateToDisk(uri), + translateToVirtual: (uri) => translator.translateToVirtual(uri), + }; +} diff --git a/src/test/vfs.e2e.test.ts b/src/test/vfs.e2e.test.ts new file mode 100644 index 00000000..4b3adfa0 --- /dev/null +++ b/src/test/vfs.e2e.test.ts @@ -0,0 +1,184 @@ +import * as vscode from "vscode"; +import * as assert from "assert"; +import { activateExtension, sleep } from "./helper"; +import type { ITestVfsExtension } from "./testVfsExtension/src/extension"; + +function waitForDiagnostics(uri: vscode.Uri, timeoutMs: number): Promise { + return new Promise((resolve) => { + const existing = vscode.languages.getDiagnostics(uri); + if (existing.length > 0) { + resolve(existing); + return; + } + const timer = setTimeout(() => { + disposable.dispose(); + resolve(vscode.languages.getDiagnostics(uri)); + }, timeoutMs); + const disposable = vscode.languages.onDidChangeDiagnostics(() => { + const diags = vscode.languages.getDiagnostics(uri); + if (diags.length > 0) { + clearTimeout(timer); + disposable.dispose(); + resolve(diags); + } + }); + }); +} + +async function waitForServerReady(docUri: vscode.Uri, timeoutMs: number): Promise { + const doc = await vscode.workspace.openTextDocument(docUri); + await vscode.window.showTextDocument(doc); + const diags = await waitForDiagnostics(docUri, timeoutMs); + return diags.length > 0; +} + +suite("VFS E2E tests", () => { + const TIMEOUT = 120000; + let vfsApi!: ITestVfsExtension; + + suiteSetup(async function () { + this.timeout(TIMEOUT); + + // Remove any file:// workspace folders (test runner may add testFixture) + // to ensure a pure VFS workspace for isVirtualWorkspace() to return true. + const folders = vscode.workspace.workspaceFolders ?? []; + const fileFolders = folders.filter((f) => f.uri.scheme === "file"); + if (fileFolders.length > 0) { + const hasVfs = folders.some((f) => f.uri.scheme === "testvfs"); + if (!hasVfs) { + // Add VFS folder first if missing + vscode.workspace.updateWorkspaceFolders(folders.length, 0, { + uri: vscode.Uri.parse("testvfs:/project"), + name: "VFS Test Project", + }); + await sleep(1000); + } + // Remove all file:// folders + const updatedFolders = vscode.workspace.workspaceFolders ?? []; + for (let i = updatedFolders.length - 1; i >= 0; i--) { + if (updatedFolders[i].uri.scheme === "file") { + vscode.workspace.updateWorkspaceFolders(i, 1); + } + } + await sleep(1000); + } + + // Verify pure VFS workspace + const currentFolders = vscode.workspace.workspaceFolders ?? []; + assert.ok(currentFolders.length > 0, "Should have workspace folders"); + assert.ok( + currentFolders.every((f) => f.uri.scheme !== "file"), + `Expected no file:// folders, got: ${currentFolders.map((f) => f.uri.toString()).join(", ")}`, + ); + + // Activate VFS test extension first (registers FileSystemProvider) + const vfsExtension = vscode.extensions.getExtension("ruff.ruff-testvfs"); + assert.ok(vfsExtension, "VFS test extension should be found"); + vfsApi = vfsExtension!.isActive + ? vfsExtension!.exports + : await vfsExtension!.activate(); + + // Activate Ruff (the VFS extension already registered translator in its activate) + await activateExtension(); + + // Wait for everything to settle — translator registration triggers server restart + await sleep(15000); + + // Open a file and wait for diagnostics to confirm server is ready + const testUri = vscode.Uri.parse("testvfs:/project/diagnostics.py"); + const ready = await waitForServerReady(testUri, 45000); + assert.ok(ready, "Ruff server should start and provide diagnostics"); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + }); + + teardown(async () => { + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + }); + + test("Ruff should export registerUriTranslator API", async () => { + const ruffExtension = vscode.extensions.getExtension("charliermarsh.ruff"); + assert.ok(ruffExtension?.isActive, "Ruff should be active"); + assert.ok( + typeof ruffExtension!.exports?.registerUriTranslator === "function", + "Should export registerUriTranslator", + ); + }); + + test("Translator should translate URIs bidirectionally", function () { + const virtualUri = vscode.Uri.parse("testvfs:/project/diagnostics.py"); + const diskUri = vfsApi.translateToDisk(virtualUri); + assert.ok(diskUri, "translateToDisk should return a URI"); + assert.equal(diskUri!.scheme, "file", "Should translate to file:// scheme"); + + const reversed = vfsApi.translateToVirtual(diskUri!); + assert.ok(reversed, "translateToVirtual should return a URI"); + assert.equal(reversed!.toString(), virtualUri.toString(), "Round-trip should match"); + }); + + test("Ruff should provide diagnostics for VFS Python file", async function () { + this.timeout(TIMEOUT); + + const docUri = vscode.Uri.parse("testvfs:/project/diagnostics.py"); + const doc = await vscode.workspace.openTextDocument(docUri); + await vscode.window.showTextDocument(doc); + + const diagnostics = await waitForDiagnostics(docUri, 30000); + assert.ok(diagnostics.length > 0, "Should provide diagnostics for VFS file"); + + const messages = diagnostics.map((d) => d.message); + const hasImportIssue = messages.some( + (m) => m.toLowerCase().includes("import") || m.toLowerCase().includes("redefin"), + ); + assert.ok(hasImportIssue, `Expected import issue, got: ${messages.join("; ")}`); + }); + + test("Ruff should format VFS Python file", async function () { + this.timeout(TIMEOUT); + + // Use a unique file to avoid race with parallel extension hosts + const fileName = `format_${Date.now()}.py`; + const docUri = vscode.Uri.parse(`testvfs:/project/${fileName}`); + vfsApi.writeFile( + docUri, + Buffer.from("def function(foo,bar,):\n print('hello world')\n"), + ); + + const doc = await vscode.workspace.openTextDocument(docUri); + await vscode.window.showTextDocument(doc); + await sleep(3000); + + const before = doc.getText(); + await vscode.commands.executeCommand("editor.action.formatDocument"); + await sleep(3000); + + const after = doc.getText(); + assert.notEqual(after, before, "Formatting should change the document"); + }); + + test("Ruff should apply auto-fix on VFS file", async function () { + this.timeout(TIMEOUT); + + // Write content with known fixable issues fresh + const docUri = vscode.Uri.parse("testvfs:/project/diagnostics.py"); + vfsApi.writeFile( + docUri, + Buffer.from( + "import os\nimport sys\nimport os\n\ndef greet(name):\n" + + ' print(f"Hello, {name}!")\n\ngreet("world")\n', + ), + ); + + const doc = await vscode.workspace.openTextDocument(docUri); + await vscode.window.showTextDocument(doc); + await waitForDiagnostics(docUri, 30000); + + const before = doc.getText(); + await vscode.commands.executeCommand("ruff.executeAutofix"); + await sleep(5000); + + const after = doc.getText(); + assert.notEqual(after, before, "Auto-fix should change the document"); + const importCount = (after.match(/import os/g) || []).length; + assert.ok(importCount <= 1, `Should have at most 1 'import os', got ${importCount}`); + }); +});