diff --git a/package.json b/package.json index 5186039..f6e6ed7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "packageManager": "yarn@1.22.19", "engines": { - "vscode": "^1.75.0" + "vscode": "^1.102.0" }, "categories": [ "Formatters", diff --git a/src/clientManager.ts b/src/clientManager.ts new file mode 100644 index 0000000..667ec72 --- /dev/null +++ b/src/clientManager.ts @@ -0,0 +1,291 @@ +import { + Diagnostic, + TextDocument, + WorkspaceFolder, + workspace +} from 'vscode' +import { + DidOpenTextDocumentNotification, + Disposable, + LanguageClient +} from 'vscode-languageclient/node' + +export interface ClientManagerOptions { + log: (message: string) => void + createClient: (folder: WorkspaceFolder) => Promise + shouldEnableForFolder: (folder: WorkspaceFolder) => Promise + onError: (message: string, folder: WorkspaceFolder) => Promise + onStatusUpdate: () => void + supportedLanguage: (languageId: string) => boolean +} + +/** + * Manages multiple language clients for multi-root workspace support. + * + * VS Code multi-root workspaces can contain folders with different Standard Ruby + * configurations (e.g., one folder using standard-rails plugin, another using plain + * standard). This class ensures each folder gets its own language server instance + * running with the correct configuration. + */ +export class ClientManager { + // One language client per workspace folder, keyed by folder URI + private readonly clients: Map = new Map() + + // Diagnostic cache per folder - used by status bar and middleware + private readonly diagnosticCaches: Map> = new Map() + + // Track file system watchers per folder so we can dispose them when stopping servers + private readonly watchers: Map = new Map() + + // Track folders with server start in progress to prevent race conditions + private readonly pendingStarts: Set = new Set() + + private readonly options: ClientManagerOptions + + constructor (options: ClientManagerOptions) { + this.options = options + } + + /** + * Get the language client for a document's workspace folder. + */ + getClient (document: TextDocument): LanguageClient | undefined { + const folder = workspace.getWorkspaceFolder(document.uri) + if (folder == null) return undefined + return this.clients.get(this.getFolderKey(folder)) + } + + /** + * Get the first available client. + */ + getFirstClient (): LanguageClient | null { + return this.clients.values().next().value ?? null + } + + /** + * Get the number of active clients. + */ + get size (): number { + return this.clients.size + } + + /** + * Iterate over all clients. + */ + values (): IterableIterator { + return this.clients.values() + } + + /** + * Get diagnostics for a document from its folder's cache. + */ + getDiagnostics (document: TextDocument): Diagnostic[] | undefined { + const folder = workspace.getWorkspaceFolder(document.uri) + if (folder == null) return undefined + return this.diagnosticCaches.get(this.getFolderKey(folder))?.get(document.uri.toString()) + } + + /** + * Get the diagnostic cache for a folder. + */ + getDiagnosticCacheForFolder (folder: WorkspaceFolder): Map { + const key = this.getFolderKey(folder) + let cache = this.diagnosticCaches.get(key) + if (cache == null) { + cache = new Map() + this.diagnosticCaches.set(key, cache) + } + return cache + } + + /** + * Register file system watchers for a folder. + * + * Watchers are tracked here so they can be properly disposed when a folder's + * language server is stopped, preventing resource leaks. + */ + registerWatchers (folder: WorkspaceFolder, watcherDisposables: Disposable[]): void { + this.watchers.set(this.getFolderKey(folder), watcherDisposables) + } + + /** + * Start the language server for a specific workspace folder. + */ + async startForFolder (folder: WorkspaceFolder): Promise { + const key = this.getFolderKey(folder) + + // Already running for this folder + if (this.clients.has(key)) return + + // Prevent race condition: startForFolder can be called multiple times concurrently + // (e.g., from workspace folder listener and manual start command). Without this + // guard, we could end up with duplicate servers for the same folder. + if (this.pendingStarts.has(key)) return + this.pendingStarts.add(key) + + try { + if (!(await this.options.shouldEnableForFolder(folder))) { + this.options.log(`Skipping workspace folder "${folder.name}" - extension disabled or not applicable`) + return + } + + const client = await this.options.createClient(folder) + if (client != null) { + this.clients.set(key, client) + await client.start() + await this.afterStart(client, folder) + this.options.log(`Language server started for "${folder.name}"`) + } + } catch (error) { + // Clean up partial state on failure + this.clients.delete(key) + this.cleanupWatchers(key) + this.options.log(`Failed to start language server for "${folder.name}": ${String(error)}`) + await this.options.onError(`Failed to start Standard Ruby Language Server for "${folder.name}"`, folder) + } finally { + this.pendingStarts.delete(key) + } + } + + /** + * Stop the language server for a specific workspace folder. + */ + async stopForFolder (folder: WorkspaceFolder): Promise { + const key = this.getFolderKey(folder) + const client = this.clients.get(key) + if (client == null) return + + this.options.log(`Stopping language server for "${folder.name}"...`) + await client.stop() + this.clients.delete(key) + this.diagnosticCaches.delete(key) + this.cleanupWatchers(key) + } + + /** + * Start language servers for all workspace folders. + */ + async startAll (): Promise { + for (const folder of workspace.workspaceFolders ?? []) { + await this.startForFolder(folder) + } + } + + /** + * Stop all language servers. + */ + async stopAll (): Promise { + this.options.log('Stopping all language servers...') + for (const client of this.clients.values()) { + await client.stop() + } + this.clients.clear() + this.diagnosticCaches.clear() + this.cleanupAllWatchers() + } + + /** + * Restart all language servers. + */ + async restartAll (): Promise { + this.options.log('Restarting all language servers...') + await this.stopAll() + await this.startAll() + } + + /** + * Create a disposable that handles workspace folder changes. + */ + createWorkspaceFolderListener (): Disposable { + return workspace.onDidChangeWorkspaceFolders(async event => { + for (const folder of event.removed) { + await this.stopForFolder(folder) + } + for (const folder of event.added) { + await this.startForFolder(folder) + } + }) + } + + /** + * Send a document open notification if needed. + * + * When the user switches to a document that the language server hasn't seen yet + * (not in the diagnostic cache), we notify the server so it can provide diagnostics. + * This handles the case where documents were opened before the server started. + */ + async notifyDocumentOpenIfNeeded (document: TextDocument): Promise { + if (!this.options.supportedLanguage(document.languageId)) return + + const folder = workspace.getWorkspaceFolder(document.uri) + if (folder == null) return + + const client = this.clients.get(this.getFolderKey(folder)) + if (client == null) return + + // If we haven't received diagnostics for this document, the server doesn't know + // about it yet. Send an open notification so the server can lint it. + const cache = this.getDiagnosticCacheForFolder(folder) + if (!cache.has(document.uri.toString())) { + await client.sendNotification( + DidOpenTextDocumentNotification.type, + client.code2ProtocolConverter.asOpenTextDocumentParams(document) + ) + } + } + + private getFolderKey (folder: WorkspaceFolder): string { + return folder.uri.toString() + } + + private async afterStart (client: LanguageClient, folder: WorkspaceFolder): Promise { + this.diagnosticCaches.set(this.getFolderKey(folder), new Map()) + await this.syncOpenDocuments(client, folder) + this.options.onStatusUpdate() + } + + /** + * Notify the language server about all documents that are already open in this folder. + * + * When a language server starts, it doesn't know about documents that were opened + * before it was running. This method sends open notifications for all such documents + * so the server can provide immediate diagnostics without waiting for the user to + * edit or switch tabs. + */ + private async syncOpenDocuments (client: LanguageClient, folder: WorkspaceFolder): Promise { + const key = this.getFolderKey(folder) + for (const doc of workspace.textDocuments) { + if (!this.options.supportedLanguage(doc.languageId)) continue + const docFolder = workspace.getWorkspaceFolder(doc.uri) + if (docFolder == null || this.getFolderKey(docFolder) !== key) continue + + await client.sendNotification( + DidOpenTextDocumentNotification.type, + client.code2ProtocolConverter.asOpenTextDocumentParams(doc) + ) + } + } + + private cleanupWatchers (key: string): void { + this.watchers.get(key)?.forEach(w => w.dispose()) + this.watchers.delete(key) + } + + private cleanupAllWatchers (): void { + for (const watcherList of this.watchers.values()) { + watcherList.forEach(w => w.dispose()) + } + this.watchers.clear() + } +} + +/** + * Normalize path for glob patterns. + * + * Windows uses backslashes in file paths (C:\Users\...) but glob patterns require + * forward slashes to work correctly. This function converts backslashes to forward + * slashes so globs work cross-platform. + */ +export function normalizePathForGlob (fsPath: string): string { + return fsPath.replace(/\\/g, '/') +} diff --git a/src/extension.ts b/src/extension.ts index ebb71b5..1b4b30d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,10 +3,10 @@ import { homedir } from 'os' import * as path from 'path' import { satisfies } from 'semver' import { - Diagnostic, DiagnosticSeverity, ExtensionContext, OutputChannel, + WorkspaceFolder, commands, window, workspace, @@ -18,7 +18,6 @@ import { StatusBarItem } from 'vscode' import { - DidOpenTextDocumentNotification, Disposable, Executable, ExecuteCommandRequest, @@ -26,6 +25,7 @@ import { LanguageClientOptions, RevealOutputChannelOn } from 'vscode-languageclient/node' +import { ClientManager, normalizePathForGlob } from './clientManager' class ExecError extends Error { command: string @@ -54,7 +54,7 @@ class ExecError extends Error { } } -const promiseExec = async function (command: string, options = { cwd: getCwd() }): Promise<{ stdout: string, stderr: string }> { +const promiseExec = async function (command: string, options: { cwd: string }): Promise<{ stdout: string, stderr: string }> { return await new Promise((resolve, reject) => { exec(command, options, (error, stdout, stderr) => { stdout = stdout.toString().trim() @@ -68,13 +68,18 @@ const promiseExec = async function (command: string, options = { cwd: getCwd() } }) } -export let languageClient: LanguageClient | null = null +// Multi-root workspace support via ClientManager +let clientManager: ClientManager | null = null let outputChannel: OutputChannel | undefined let statusBarItem: StatusBarItem | undefined -let diagnosticCache: Map = new Map() -function getCwd (): string { - return workspace.workspaceFolders?.[0]?.uri?.fsPath ?? process.cwd() +// Public API for accessing language clients +export function getLanguageClient (): LanguageClient | null { + return clientManager?.getFirstClient() ?? null +} +export const languageClients = { + get size (): number { return clientManager?.size ?? 0 }, + values (): IterableIterator { return clientManager?.values() ?? [].values() } } function log (s: string): void { @@ -91,22 +96,26 @@ function supportedLanguage (languageId: string): boolean { function registerCommands (): Disposable[] { return [ - commands.registerCommand('standardRuby.start', startLanguageServer), - commands.registerCommand('standardRuby.stop', stopLanguageServer), - commands.registerCommand('standardRuby.restart', restartLanguageServer), + commands.registerCommand('standardRuby.start', async () => await clientManager?.startAll()), + commands.registerCommand('standardRuby.stop', async () => await clientManager?.stopAll()), + commands.registerCommand('standardRuby.restart', async () => await clientManager?.restartAll()), commands.registerCommand('standardRuby.showOutputChannel', () => outputChannel?.show()), commands.registerCommand('standardRuby.formatAutoFixes', formatAutoFixes) ] } function registerWorkspaceListeners (): Disposable[] { - return [ + const listeners: Disposable[] = [ workspace.onDidChangeConfiguration(async event => { if (event.affectsConfiguration('standardRuby')) { - await restartLanguageServer() + await clientManager?.restartAll() } }) ] + if (clientManager != null) { + listeners.push(clientManager.createWorkspaceFolderListener()) + } + return listeners } export enum BundleStatus { @@ -121,34 +130,34 @@ export enum StandardBundleStatus { errored = 2 } -async function displayBundlerError (e: ExecError): Promise { +async function displayBundlerError (e: ExecError, folder: WorkspaceFolder): Promise { e.log() - log('Failed to invoke Bundler in the current workspace. After resolving the issue, run the command `Standard Ruby: Start Language Server`') + log(`Failed to invoke Bundler in workspace folder "${folder.name}". After resolving the issue, run the command \`Standard Ruby: Start Language Server\``) if (getConfig('mode') !== 'enableUnconditionally') { - await displayError('Failed to run Bundler while initializing Standard Ruby', ['Show Output']) + await displayError(`Failed to run Bundler in "${folder.name}" while initializing Standard Ruby`, ['Show Output']) } } -async function isValidBundlerProject (): Promise { +async function isValidBundlerProject (folder: WorkspaceFolder): Promise { try { - await promiseExec('bundle list --name-only', { cwd: getCwd() }) + await promiseExec('bundle list --name-only', { cwd: folder.uri.fsPath }) return BundleStatus.valid } catch (e) { if (!(e instanceof ExecError)) return BundleStatus.errored if (e.stderr.startsWith('Could not locate Gemfile')) { - log('No Gemfile found in the current workspace') + log(`No Gemfile found in workspace folder "${folder.name}"`) return BundleStatus.missing } else { - await displayBundlerError(e) + await displayBundlerError(e, folder) return BundleStatus.errored } } } -async function isInBundle (): Promise { +async function isInBundle (folder: WorkspaceFolder): Promise { try { - await promiseExec('bundle show standard', { cwd: getCwd() }) + await promiseExec('bundle show standard', { cwd: folder.uri.fsPath }) return StandardBundleStatus.included } catch (e) { if (!(e instanceof ExecError)) return StandardBundleStatus.errored @@ -156,34 +165,34 @@ async function isInBundle (): Promise { if (e.stderr.startsWith('Could not locate Gemfile') || e.stderr === 'Could not find gem \'standard\'.') { return StandardBundleStatus.excluded } else { - await displayBundlerError(e) + await displayBundlerError(e, folder) return StandardBundleStatus.errored } } } -async function shouldEnableIfBundleIncludesStandard (): Promise { - const standardStatus = await isInBundle() +async function shouldEnableIfBundleIncludesStandard (folder: WorkspaceFolder): Promise { + const standardStatus = await isInBundle(folder) if (standardStatus === StandardBundleStatus.excluded) { - log('Disabling Standard Ruby extension, because standard isn\'t included in the bundle') + log(`Skipping workspace folder "${folder.name}" - standard gem not in bundle`) } return standardStatus === StandardBundleStatus.included } -async function shouldEnableExtension (): Promise { +async function shouldEnableForFolder (folder: WorkspaceFolder): Promise { let bundleStatus switch (getConfig('mode')) { case 'enableUnconditionally': return true case 'enableViaGemfileOrMissingGemfile': - bundleStatus = await isValidBundlerProject() + bundleStatus = await isValidBundlerProject(folder) if (bundleStatus === BundleStatus.valid) { - return await shouldEnableIfBundleIncludesStandard() + return await shouldEnableIfBundleIncludesStandard(folder) } else { return bundleStatus === BundleStatus.missing } case 'enableViaGemfile': - return await shouldEnableIfBundleIncludesStandard() + return await shouldEnableIfBundleIncludesStandard(folder) case 'onlyRunGlobally': return true case 'disable': @@ -200,13 +209,13 @@ function hasCustomizedCommandPath (): boolean { } const variablePattern = /\$\{([^}]*)\}/ -function resolveCommandPath (): string { +function resolveCommandPath (folder: WorkspaceFolder): string { let customCommandPath = getConfig('commandPath') ?? '' for (let match = variablePattern.exec(customCommandPath); match != null; match = variablePattern.exec(customCommandPath)) { switch (match[1]) { case 'cwd': - customCommandPath = customCommandPath.replace(match[0], process.cwd()) + customCommandPath = customCommandPath.replace(match[0], folder.uri.fsPath) break case 'pathSeparator': customCommandPath = customCommandPath.replace(match[0], path.sep) @@ -220,10 +229,10 @@ function resolveCommandPath (): string { return customCommandPath } -async function getCommand (): Promise { +async function getCommand (folder: WorkspaceFolder): Promise { if (hasCustomizedCommandPath()) { - return resolveCommandPath() - } else if (getConfig('mode') !== 'onlyRunGlobally' && await isInBundle() === StandardBundleStatus.included) { + return resolveCommandPath(folder) + } else if (getConfig('mode') !== 'onlyRunGlobally' && await isInBundle(folder) === StandardBundleStatus.included) { return 'bundle exec standardrb' } else { return 'standardrb' @@ -231,57 +240,70 @@ async function getCommand (): Promise { } const requiredGemVersion = '>= 1.24.3' -async function supportedVersionOfStandard (command: string): Promise { +async function supportedVersionOfStandard (command: string, folder: WorkspaceFolder): Promise { try { - const { stdout } = await promiseExec(`${command} -v`) + const { stdout } = await promiseExec(`${command} -v`, { cwd: folder.uri.fsPath }) const version = stdout.trim() if (satisfies(version, requiredGemVersion)) { return true } else { - log('Disabling because the extension does not support this version of the standard gem.') + log(`Disabling for "${folder.name}" - unsupported standard version.`) log(` Version reported by \`${command} -v\`: ${version} (${requiredGemVersion} required)`) - await displayError(`Unsupported standard version: ${version} (${requiredGemVersion} required)`, ['Show Output']) + await displayError(`Unsupported standard version in "${folder.name}": ${version} (${requiredGemVersion} required)`, ['Show Output']) return false } } catch (e) { if (e instanceof ExecError) e.log() - log('Failed to verify the version of standard installed, proceeding anyway…') + log(`Failed to verify the version of standard in "${folder.name}", proceeding anyway…`) return true } } -async function buildExecutable (): Promise { - const command = await getCommand() +async function buildExecutable (folder: WorkspaceFolder): Promise { + const command = await getCommand(folder) if (command == null) { - await displayError('Could not find Standard Ruby executable', ['Show Output', 'View Settings']) - } else if (await supportedVersionOfStandard(command)) { + await displayError(`Could not find Standard Ruby executable for "${folder.name}"`, ['Show Output', 'View Settings']) + } else if (await supportedVersionOfStandard(command, folder)) { const [exe, ...args] = (command).split(' ') return { command: exe, - args: args.concat('--lsp') + args: args.concat('--lsp'), + options: { + cwd: folder.uri.fsPath + } } } } -function buildLanguageClientOptions (): LanguageClientOptions { +function buildLanguageClientOptions (folder: WorkspaceFolder): LanguageClientOptions { + const globPath = normalizePathForGlob(folder.uri.fsPath) + + // Create watchers and register them with the client manager + const watchers = [ + workspace.createFileSystemWatcher(`${globPath}/**/.standard.yml`), + workspace.createFileSystemWatcher(`${globPath}/**/.standard_todo.yml`), + workspace.createFileSystemWatcher(`${globPath}/**/Gemfile.lock`) + ] + clientManager?.registerWatchers(folder, watchers) + + // Get the diagnostic cache for this folder + const diagnosticCache = clientManager?.getDiagnosticCacheForFolder(folder) ?? new Map() + return { documentSelector: [ - { scheme: 'file', language: 'ruby' }, - { scheme: 'file', pattern: '**/Gemfile' } + { scheme: 'file', language: 'ruby', pattern: `${globPath}/**/*` }, + { scheme: 'file', pattern: `${globPath}/**/Gemfile` } ], - diagnosticCollectionName: 'standardRuby', + diagnosticCollectionName: `standardRuby-${folder.name}`, + workspaceFolder: folder, initializationFailedHandler: (error) => { - log(`Language server initialization failed: ${String(error)}`) + log(`Language server initialization failed for "${folder.name}": ${String(error)}`) return false }, revealOutputChannelOn: RevealOutputChannelOn.Never, outputChannel, synchronize: { - fileEvents: [ - workspace.createFileSystemWatcher('**/.standard.yml'), - workspace.createFileSystemWatcher('**/.standard_todo.yml'), - workspace.createFileSystemWatcher('**/Gemfile.lock') - ] + fileEvents: watchers }, middleware: { provideDocumentFormattingEdits: (document, options, token, next): ProviderResult => { @@ -298,11 +320,15 @@ function buildLanguageClientOptions (): LanguageClientOptions { } } -async function createLanguageClient (): Promise { - const run = await buildExecutable() +async function createLanguageClient (folder: WorkspaceFolder): Promise { + const run = await buildExecutable(folder) if (run != null) { - log(`Starting language server: ${run.command} ${run.args?.join(' ') ?? ''}`) - return new LanguageClient('Standard Ruby', { run, debug: run }, buildLanguageClientOptions()) + log(`Starting language server for "${folder.name}": ${run.command} ${run.args?.join(' ') ?? ''} (cwd: ${folder.uri.fsPath})`) + return new LanguageClient( + `Standard Ruby (${folder.name})`, + { run, debug: run }, + buildLanguageClientOptions(folder) + ) } else { return null } @@ -312,7 +338,7 @@ async function displayError (message: string, actions: string[]): Promise const action = await window.showErrorMessage(message, ...actions) switch (action) { case 'Restart': - await restartLanguageServer() + await clientManager?.restartAll() break case 'Show Output': outputChannel?.show() @@ -325,72 +351,25 @@ async function displayError (message: string, actions: string[]): Promise } } -async function syncOpenDocumentsWithLanguageServer (languageClient: LanguageClient): Promise { - for (const textDocument of workspace.textDocuments) { - if (supportedLanguage(textDocument.languageId)) { - await languageClient.sendNotification( - DidOpenTextDocumentNotification.type, - languageClient.code2ProtocolConverter.asOpenTextDocumentParams(textDocument) - ) - } - } -} - async function handleActiveTextEditorChange (editor: TextEditor | undefined): Promise { - if (languageClient == null || editor == null) return - - if (supportedLanguage(editor.document.languageId) && !diagnosticCache.has(editor.document.uri.toString())) { - await languageClient.sendNotification( - DidOpenTextDocumentNotification.type, - languageClient.code2ProtocolConverter.asOpenTextDocumentParams(editor.document) - ) + if (clientManager == null || editor == null) { + updateStatusBar() + return } - updateStatusBar() -} -async function afterStartLanguageServer (languageClient: LanguageClient): Promise { - diagnosticCache = new Map() - await syncOpenDocumentsWithLanguageServer(languageClient) + await clientManager.notifyDocumentOpenIfNeeded(editor.document) updateStatusBar() } -async function startLanguageServer (): Promise { - if (languageClient != null || !(await shouldEnableExtension())) return - - try { - languageClient = await createLanguageClient() - if (languageClient != null) { - await languageClient.start() - await afterStartLanguageServer(languageClient) - } - } catch (error) { - languageClient = null - await displayError( - 'Failed to start Standard Ruby Language Server', ['Restart', 'Show Output'] - ) - } -} - -async function stopLanguageServer (): Promise { - if (languageClient == null) return - - log('Stopping language server...') - await languageClient.stop() - languageClient = null -} - -async function restartLanguageServer (): Promise { - log('Restarting language server...') - await stopLanguageServer() - await startLanguageServer() -} - async function formatAutoFixes (): Promise { const editor = window.activeTextEditor - if (editor == null || languageClient == null || !supportedLanguage(editor.document.languageId)) return + if (editor == null || !supportedLanguage(editor.document.languageId)) return + + const client = clientManager?.getClient(editor.document) + if (client == null) return try { - await languageClient.sendRequest(ExecuteCommandRequest.type, { + await client.sendRequest(ExecuteCommandRequest.type, { command: 'standardRuby.formatAutoFixes', arguments: [{ uri: editor.document.uri.toString(), @@ -414,47 +393,68 @@ function updateStatusBar (): void { if (statusBarItem == null) return const editor = window.activeTextEditor - if (languageClient == null || editor == null || !supportedLanguage(editor.document.languageId)) { + if (clientManager == null || editor == null || !supportedLanguage(editor.document.languageId)) { + statusBarItem.hide() + return + } + + const client = clientManager.getClient(editor.document) + if (client == null) { statusBarItem.hide() + return + } + + const diagnostics = clientManager.getDiagnostics(editor.document) + + if (diagnostics == null) { + statusBarItem.tooltip = 'Standard Ruby' + statusBarItem.text = 'Standard $(ruby)' + statusBarItem.color = undefined + statusBarItem.backgroundColor = undefined } else { - const diagnostics = diagnosticCache.get(editor.document.uri.toString()) - if (diagnostics == null) { - statusBarItem.tooltip = 'Standard Ruby' - statusBarItem.text = 'Standard $(ruby)' - statusBarItem.color = undefined + const errorCount = diagnostics.filter((d) => d.severity === DiagnosticSeverity.Error).length + const warningCount = diagnostics.filter((d) => d.severity === DiagnosticSeverity.Warning).length + const otherCount = diagnostics.filter((d) => + d.severity === DiagnosticSeverity.Information || + d.severity === DiagnosticSeverity.Hint + ).length + if (errorCount > 0) { + statusBarItem.tooltip = `Standard Ruby: ${errorCount === 1 ? '1 error' : `${errorCount} errors`}` + statusBarItem.text = 'Standard $(error)' + statusBarItem.backgroundColor = new ThemeColor('statusBarItem.errorBackground') + } else if (warningCount > 0) { + statusBarItem.tooltip = `Standard Ruby: ${warningCount === 1 ? '1 warning' : `${warningCount} warnings`}` + statusBarItem.text = 'Standard $(warning)' + statusBarItem.backgroundColor = new ThemeColor('statusBarItem.warningBackground') + } else if (otherCount > 0) { + statusBarItem.tooltip = `Standard Ruby: ${otherCount === 1 ? '1 hint' : `${otherCount} issues`}` + statusBarItem.text = 'Standard $(info)' statusBarItem.backgroundColor = undefined } else { - const errorCount = diagnostics.filter((d) => d.severity === DiagnosticSeverity.Error).length - const warningCount = diagnostics.filter((d) => d.severity === DiagnosticSeverity.Warning).length - const otherCount = diagnostics.filter((d) => - d.severity === DiagnosticSeverity.Information || - d.severity === DiagnosticSeverity.Hint - ).length - if (errorCount > 0) { - statusBarItem.tooltip = `Standard Ruby: ${errorCount === 1 ? '1 error' : `${errorCount} errors`}` - statusBarItem.text = 'Standard $(error)' - statusBarItem.backgroundColor = new ThemeColor('statusBarItem.errorBackground') - } else if (warningCount > 0) { - statusBarItem.tooltip = `Standard Ruby: ${warningCount === 1 ? '1 warning' : `${errorCount} warnings`}` - statusBarItem.text = 'Standard $(warning)' - statusBarItem.backgroundColor = new ThemeColor('statusBarItem.warningBackground') - } else if (otherCount > 0) { - statusBarItem.tooltip = `Standard Ruby: ${otherCount === 1 ? '1 hint' : `${otherCount} issues`}` - statusBarItem.text = 'Standard $(info)' - statusBarItem.backgroundColor = undefined - } else { - statusBarItem.tooltip = 'Standard Ruby: No issues!' - statusBarItem.text = 'Standard $(ruby)' - statusBarItem.backgroundColor = undefined - } + statusBarItem.tooltip = 'Standard Ruby: No issues!' + statusBarItem.text = 'Standard $(ruby)' + statusBarItem.backgroundColor = undefined } - statusBarItem.show() } + statusBarItem.show() } export async function activate (context: ExtensionContext): Promise { outputChannel = window.createOutputChannel('Standard Ruby') statusBarItem = createStatusBarItem() + + // Initialize client manager for multi-root workspace support + clientManager = new ClientManager({ + log, + createClient: createLanguageClient, + shouldEnableForFolder, + onError: async (message, _folder) => { + await displayError(message, ['Restart', 'Show Output']) + }, + onStatusUpdate: updateStatusBar, + supportedLanguage + }) + window.onDidChangeActiveTextEditor(handleActiveTextEditorChange) context.subscriptions.push( outputChannel, @@ -463,9 +463,10 @@ export async function activate (context: ExtensionContext): Promise { ...registerWorkspaceListeners() ) - await startLanguageServer() + log('Activating Standard Ruby extension with multi-root workspace support') + await clientManager.startAll() } export async function deactivate (): Promise { - await stopLanguageServer() + await clientManager?.stopAll() } diff --git a/src/test/suite/clientManager.test.ts b/src/test/suite/clientManager.test.ts new file mode 100644 index 0000000..d9396bf --- /dev/null +++ b/src/test/suite/clientManager.test.ts @@ -0,0 +1,240 @@ +import * as assert from 'assert' +import { Uri } from 'vscode' +import { ClientManager, ClientManagerOptions, normalizePathForGlob } from '../../clientManager' + +function createMockFolder (name: string, fsPath: string): any { + return { name, uri: Uri.file(fsPath), index: 0 } +} + +function createMockClient (overrides: any = {}): any { + return { + start: async () => {}, + stop: async () => {}, + sendNotification: async () => {}, + code2ProtocolConverter: { + asOpenTextDocumentParams: (doc: any) => ({ textDocument: { uri: doc.uri.toString() } }) + }, + ...overrides + } +} + +function createOptions (overrides: Partial = {}): ClientManagerOptions { + return { + log: () => {}, + createClient: async () => createMockClient(), + shouldEnableForFolder: async () => true, + onError: async () => {}, + onStatusUpdate: () => {}, + supportedLanguage: (id) => id === 'ruby', + ...overrides + } +} + +suite('ClientManager', () => { + suite('normalizePathForGlob', () => { + test('converts Windows backslashes to forward slashes', async () => { + assert.strictEqual( + normalizePathForGlob('C:\\Users\\dev\\project'), + 'C:/Users/dev/project' + ) + }) + + test('leaves Unix paths unchanged', async () => { + assert.strictEqual( + normalizePathForGlob('/Users/dev/project'), + '/Users/dev/project' + ) + }) + + test('handles mixed separators', async () => { + assert.strictEqual( + normalizePathForGlob('C:\\Users/dev\\project'), + 'C:/Users/dev/project' + ) + }) + }) + + suite('client lifecycle', () => { + test('startForFolder creates a client for the folder', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + let clientCreated = false + + const manager = new ClientManager(createOptions({ + createClient: async () => { + clientCreated = true + return createMockClient() + } + })) + await manager.startForFolder(folder) + + assert.strictEqual(clientCreated, true) + assert.strictEqual(manager.size, 1) + }) + + test('startForFolder skips when shouldEnableForFolder returns false', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + let clientCreated = false + + const manager = new ClientManager(createOptions({ + createClient: async () => { + clientCreated = true + return createMockClient() + }, + shouldEnableForFolder: async () => false + })) + await manager.startForFolder(folder) + + assert.strictEqual(clientCreated, false) + assert.strictEqual(manager.size, 0) + }) + + test('startForFolder does not create duplicate clients', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + let createCount = 0 + + const manager = new ClientManager(createOptions({ + createClient: async () => { + createCount++ + return createMockClient() + } + })) + await manager.startForFolder(folder) + await manager.startForFolder(folder) + + assert.strictEqual(createCount, 1) + assert.strictEqual(manager.size, 1) + }) + + test('stopForFolder removes the client', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + let stopCalled = false + + const manager = new ClientManager(createOptions({ + createClient: async () => createMockClient({ stop: async () => { stopCalled = true } }) + })) + await manager.startForFolder(folder) + assert.strictEqual(manager.size, 1) + + await manager.stopForFolder(folder) + assert.strictEqual(stopCalled, true) + assert.strictEqual(manager.size, 0) + }) + }) + + suite('race condition prevention', () => { + test('concurrent startForFolder calls only create one client', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + let createCount = 0 + + const manager = new ClientManager(createOptions({ + createClient: async () => { + createCount++ + await new Promise(resolve => setTimeout(resolve, 10)) + return createMockClient() + } + })) + + await Promise.all([ + manager.startForFolder(folder), + manager.startForFolder(folder) + ]) + + assert.strictEqual(createCount, 1) + assert.strictEqual(manager.size, 1) + }) + }) + + suite('multiple folders', () => { + test('manages separate clients for different folders', async () => { + const folder1 = createMockFolder('rails-app', '/workspace/rails-app') + const folder2 = createMockFolder('cli-tool', '/workspace/cli-tool') + let clientCount = 0 + + const manager = new ClientManager(createOptions({ + createClient: async () => { + clientCount++ + return createMockClient() + } + })) + await manager.startForFolder(folder1) + await manager.startForFolder(folder2) + + assert.strictEqual(manager.size, 2) + assert.strictEqual(clientCount, 2) + }) + + test('stopAll stops all clients', async () => { + const folder1 = createMockFolder('rails-app', '/workspace/rails-app') + const folder2 = createMockFolder('cli-tool', '/workspace/cli-tool') + let stopCount = 0 + + const manager = new ClientManager(createOptions({ + createClient: async () => createMockClient({ stop: async () => { stopCount++ } }) + })) + await manager.startForFolder(folder1) + await manager.startForFolder(folder2) + assert.strictEqual(manager.size, 2) + + await manager.stopAll() + assert.strictEqual(stopCount, 2) + assert.strictEqual(manager.size, 0) + }) + }) + + suite('diagnostic cache', () => { + test('getDiagnosticCacheForFolder returns consistent cache', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + const manager = new ClientManager(createOptions()) + + const cache1 = manager.getDiagnosticCacheForFolder(folder) + const cache2 = manager.getDiagnosticCacheForFolder(folder) + + assert.strictEqual(cache1, cache2) + }) + }) + + suite('watcher cleanup', () => { + test('stopForFolder disposes watchers', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + let watcherDisposed = false + + const manager = new ClientManager(createOptions()) + await manager.startForFolder(folder) + manager.registerWatchers(folder, [{ dispose: () => { watcherDisposed = true } }]) + + await manager.stopForFolder(folder) + assert.strictEqual(watcherDisposed, true) + }) + }) + + suite('error handling', () => { + test('startForFolder calls onError when client creation fails', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + let errorMessage = '' + + const manager = new ClientManager(createOptions({ + createClient: async () => { throw new Error('Connection failed') }, + onError: async (message) => { errorMessage = message } + })) + await manager.startForFolder(folder) + + assert.ok(errorMessage.includes('my-app')) + assert.strictEqual(manager.size, 0) + }) + + test('startForFolder cleans up on failure', async () => { + const folder = createMockFolder('my-app', '/workspace/my-app') + let watcherDisposed = false + + const manager = new ClientManager(createOptions({ + createClient: async () => { throw new Error('Connection failed') } + })) + manager.registerWatchers(folder, [{ dispose: () => { watcherDisposed = true } }]) + + await manager.startForFolder(folder) + + assert.strictEqual(watcherDisposed, true) + assert.strictEqual(manager.size, 0) + }) + }) +}) diff --git a/src/test/suite/index.test.ts b/src/test/suite/index.test.ts index f075dcb..83ba736 100644 --- a/src/test/suite/index.test.ts +++ b/src/test/suite/index.test.ts @@ -25,20 +25,22 @@ suite('Standard Ruby', () => { suite('lifecycle commands', () => { test('start', async () => { await auto.start() - assert.notEqual(extension.languageClient, null) - assert.equal(extension.languageClient?.state, State.Running) + const client = extension.getLanguageClient() + assert.notEqual(client, null) + assert.equal(client?.state, State.Running) }) test('stop', async () => { await auto.start() await auto.stop() - assert.equal(extension.languageClient, null) + assert.equal(extension.languageClients.size, 0) }) test('restart', async () => { await auto.restart() - assert.notEqual(extension.languageClient, null) - assert.equal(extension.languageClient?.state, State.Running) + const client = extension.getLanguageClient() + assert.notEqual(client, null) + assert.equal(client?.state, State.Running) }) }) diff --git a/test-fixtures/multi-root-demo/.gitignore b/test-fixtures/multi-root-demo/.gitignore new file mode 100644 index 0000000..c7798fd --- /dev/null +++ b/test-fixtures/multi-root-demo/.gitignore @@ -0,0 +1,2 @@ +# Auto-generated by Ruby LSP +.ruby-lsp/ diff --git a/test-fixtures/multi-root-demo/README.md b/test-fixtures/multi-root-demo/README.md new file mode 100644 index 0000000..e6336c2 --- /dev/null +++ b/test-fixtures/multi-root-demo/README.md @@ -0,0 +1,87 @@ +# Multi-Root Workspace Test Fixture + +This fixture demonstrates and tests multi-root workspace support for the Standard Ruby extension. + +## The Problem This Solves + +Without multi-root support, the extension uses the **first** workspace folder's Standard Ruby configuration for **all** files. This causes false positives when different folders have different configurations. + +In this example: +- `app-rails/` uses `standard-rails` (which includes Rails-specific cops like `Rails/Output`) +- `ruby-lib/` uses plain `standard` (no Rails cops) + +The Rails folder is named `app-rails` so it comes first alphabetically. This is important because without multi-root support, the **first** folder's config is used for all files. + +## How to Test + +### Setup + +Install dependencies in both folders: + +```bash +cd test-fixtures/multi-root-demo +cd app-rails && bundle install +cd ../ruby-lib && bundle install +``` + +### Step 1: See the Bug (Published Extension) + +First, verify the bug exists with the current published extension: + +1. Open this workspace in VS Code (not Extension Development Host): + - File > Open Workspace from File... + - Select `test-fixtures/multi-root-demo/multi-root-demo.code-workspace` + +2. Open `ruby-lib/lib/string_utils.rb` + +3. You should see `Rails/Output: Do not write to stdout` errors on the `puts` statement - this is the bug. The Rails config from `app-rails` is being incorrectly applied to `ruby-lib`. + +![Rails/Output error shown incorrectly on ruby-lib](rails-output-error.png) + +### Step 2: Verify the Fix (Extension Development Host) + +Now verify the fix works: + +1. Open the vscode-standard-ruby project in VS Code +2. Press F5 to launch the Extension Development Host +3. In the Extension Development Host, open this workspace: + - File > Open Workspace from File... + - Select `test-fixtures/multi-root-demo/multi-root-demo.code-workspace` + +4. Check the Output panel (View > Output > Standard Ruby): + - You should see TWO language servers starting: + ``` + Starting language server for "app-rails": ... + Starting language server for "ruby-lib": ... + ``` + +5. Open `ruby-lib/lib/string_utils.rb`: + - The `puts` statement should have NO errors + - Each folder now gets its own language server with its own config + +### Optional: Test Folder Add/Remove + +- Remove one folder from the workspace +- Verify its language server stops in the Output panel +- Add it back +- Verify its language server restarts + +## File Structure + +``` +multi-root-demo/ +├── multi-root-demo.code-workspace # VS Code workspace file +├── README.md # This file +├── app-rails/ +│ ├── .standard.yml # Uses standard-rails plugin +│ ├── Gemfile +│ └── app/controllers/users_controller.rb +└── ruby-lib/ + ├── .standard.yml # Plain standard (no Rails) + ├── Gemfile + └── lib/string_utils.rb # Has puts statement (valid for a lib) +``` + +## Note on File Location + +The test file must be in `lib/`, `app/`, `config/`, or `db/` for the `Rails/Output` cop to check it. Files in other directories (like `bin/`) are not checked by this cop, so you won't be able to reproduce the issue there. diff --git a/test-fixtures/multi-root-demo/app-rails/.standard.yml b/test-fixtures/multi-root-demo/app-rails/.standard.yml new file mode 100644 index 0000000..fcd095b --- /dev/null +++ b/test-fixtures/multi-root-demo/app-rails/.standard.yml @@ -0,0 +1,2 @@ +plugins: + - standard-rails diff --git a/test-fixtures/multi-root-demo/app-rails/Gemfile b/test-fixtures/multi-root-demo/app-rails/Gemfile new file mode 100644 index 0000000..201b5ae --- /dev/null +++ b/test-fixtures/multi-root-demo/app-rails/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "rails" + +gem "standard", require: false +gem "standard-rails", require: false diff --git a/test-fixtures/multi-root-demo/app-rails/Gemfile.lock b/test-fixtures/multi-root-demo/app-rails/Gemfile.lock new file mode 100644 index 0000000..d9c5ea1 --- /dev/null +++ b/test-fixtures/multi-root-demo/app-rails/Gemfile.lock @@ -0,0 +1,339 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.16) + railties + actioncable (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + mail (>= 2.8.0) + actionmailer (8.1.2) + actionpack (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activesupport (= 8.1.2) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.2) + actionview (= 8.1.2) + activesupport (= 8.1.2) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.2) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.2) + activesupport (= 8.1.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.2) + activesupport (= 8.1.2) + globalid (>= 0.3.6) + activemodel (8.1.2) + activesupport (= 8.1.2) + activerecord (8.1.2) + activemodel (= 8.1.2) + activesupport (= 8.1.2) + timeout (>= 0.4.0) + activestorage (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activesupport (= 8.1.2) + marcel (~> 1.0) + activesupport (8.1.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.0.1) + builder (3.3.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + crass (1.0.6) + date (3.5.1) + drb (2.2.3) + erb (6.0.1) + erubi (1.13.1) + globalid (1.3.0) + activesupport (>= 6.1) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + irb (1.16.0) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.18.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + mini_mime (1.1.5) + minitest (6.0.1) + prism (~> 1.5) + net-imap (0.6.2) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.0-arm64-darwin) + racc (~> 1.4) + parallel (1.27.0) + parser (3.3.10.1) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.8.0) + psych (5.3.1) + date + stringio + racc (1.8.1) + rack (3.2.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.2) + actioncable (= 8.1.2) + actionmailbox (= 8.1.2) + actionmailer (= 8.1.2) + actionpack (= 8.1.2) + actiontext (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activemodel (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) + bundler (>= 1.15.0) + railties (= 8.1.2) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.1) + rdoc (7.1.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rubocop (1.82.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.48.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.3) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + standard (1.53.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.82.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + standard-rails (1.6.0) + lint_roller (~> 1.0) + rubocop-rails (~> 2.34.0) + stringio (3.2.0) + thor (1.5.0) + timeout (0.6.0) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.7.4) + +PLATFORMS + arm64-darwin + +DEPENDENCIES + rails + standard + standard-rails + +CHECKSUMS + action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a + actioncable (8.1.2) sha256=dc31efc34cca9cdefc5c691ddb8b4b214c0ea5cd1372108cbc1377767fb91969 + actionmailbox (8.1.2) sha256=058b2fb1980e5d5a894f675475fcfa45c62631103d5a2596d9610ec81581889b + actionmailer (8.1.2) sha256=f4c1d2060f653bfe908aa7fdc5a61c0e5279670de992146582f2e36f8b9175e9 + actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423 + actiontext (8.1.2) sha256=0bf57da22a9c19d970779c3ce24a56be31b51c7640f2763ec64aa72e358d2d2d + actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b + activejob (8.1.2) sha256=908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825 + activemodel (8.1.2) sha256=e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e + activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44 + activestorage (8.1.2) sha256=8a63a48c3999caeee26a59441f813f94681fc35cc41aba7ce1f836add04fba76 + activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 + json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb + net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254 + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 + rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.2) sha256=5069061b23dfa8706b9f0159ae8b9d35727359103178a26962b868a680ba7d95 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 + railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363 + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 + rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + standard (1.53.0) sha256=f3c9493385db7079d0abce6f7582f553122156997b81258cd361d3480eeacf9c + standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b + standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 + standard-rails (1.6.0) sha256=8f1d19a402491901ee8148adb431682012838a73bb7d3b0e95aa339ea872497a + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b + +BUNDLED WITH + 4.0.4 diff --git a/test-fixtures/multi-root-demo/app-rails/app/controllers/users_controller.rb b/test-fixtures/multi-root-demo/app-rails/app/controllers/users_controller.rb new file mode 100644 index 0000000..f2e631e --- /dev/null +++ b/test-fixtures/multi-root-demo/app-rails/app/controllers/users_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class UsersController < ApplicationController + def index + @users = User.all + end + + def show + @user = User.find(params[:id]) + end +end diff --git a/test-fixtures/multi-root-demo/multi-root-demo.code-workspace b/test-fixtures/multi-root-demo/multi-root-demo.code-workspace new file mode 100644 index 0000000..365c83b --- /dev/null +++ b/test-fixtures/multi-root-demo/multi-root-demo.code-workspace @@ -0,0 +1,60 @@ +{ + "folders": [ + { + "name": "app-rails", + "path": "app-rails" + }, + { + "name": "ruby-lib", + "path": "ruby-lib" + } + ], + + "settings": { + // Ruby Formatting + // Using Standard Ruby extension for formatting + "[ruby]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "testdouble.vscode-standard-ruby" + }, + + // Gemfile Formatting + "[Gemfile]": { + "editor.formatOnSave": true + }, + + // Ruby LSP Configuration + // Tells Ruby LSP to use Standard for diagnostics/linting + "rubyLsp.formatter": "standard", + "rubyLsp.linters": ["standard"], + + // Enable Ruby LSP IDE features + // (diagnostics and formatting intentionally disabled) + "rubyLsp.enabledFeatures": { + "codeActions": true, + "diagnostics": false, + "documentHighlights": true, + "documentLink": true, + "documentSymbols": true, + "foldingRanges": true, + "formatting": false, + "hover": true, + "inlayHint": true, + "onTypeFormatting": true, + "selectionRanges": true, + "semanticHighlighting": true, + "completion": true, + "codeLens": true, + "definition": true, + "workspaceSymbol": true, + "signatureHelp": true + } + }, + + "extensions": { + "recommendations": [ + "shopify.ruby-lsp", + "testdouble.vscode-standard-ruby" + ] + } +} diff --git a/test-fixtures/multi-root-demo/rails-output-error.png b/test-fixtures/multi-root-demo/rails-output-error.png new file mode 100644 index 0000000..188f8ea Binary files /dev/null and b/test-fixtures/multi-root-demo/rails-output-error.png differ diff --git a/test-fixtures/multi-root-demo/ruby-lib/.standard.yml b/test-fixtures/multi-root-demo/ruby-lib/.standard.yml new file mode 100644 index 0000000..081ed83 --- /dev/null +++ b/test-fixtures/multi-root-demo/ruby-lib/.standard.yml @@ -0,0 +1,3 @@ +# https://github.com/standardrb/standard +# No plugins - this is a Ruby library, not Rails +plugins: [] diff --git a/test-fixtures/multi-root-demo/ruby-lib/Gemfile b/test-fixtures/multi-root-demo/ruby-lib/Gemfile new file mode 100644 index 0000000..13179d7 --- /dev/null +++ b/test-fixtures/multi-root-demo/ruby-lib/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "standard", require: false diff --git a/test-fixtures/multi-root-demo/ruby-lib/Gemfile.lock b/test-fixtures/multi-root-demo/ruby-lib/Gemfile.lock new file mode 100644 index 0000000..df88e1b --- /dev/null +++ b/test-fixtures/multi-root-demo/ruby-lib/Gemfile.lock @@ -0,0 +1,80 @@ +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + json (2.18.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + parallel (1.27.0) + parser (3.3.10.1) + ast (~> 2.4.1) + racc + prism (1.8.0) + racc (1.8.1) + rainbow (3.1.1) + regexp_parser (2.11.3) + rubocop (1.82.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.48.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + standard (1.53.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.82.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + standard + +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 + prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 + rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + standard (1.53.0) sha256=f3c9493385db7079d0abce6f7582f553122156997b81258cd361d3480eeacf9c + standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b + standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + +BUNDLED WITH + 4.0.4 diff --git a/test-fixtures/multi-root-demo/ruby-lib/lib/string_utils.rb b/test-fixtures/multi-root-demo/ruby-lib/lib/string_utils.rb new file mode 100644 index 0000000..0f98b63 --- /dev/null +++ b/test-fixtures/multi-root-demo/ruby-lib/lib/string_utils.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# This puts is fine for a Ruby library using plain 'standard'. +# With 'standard-rails', it would trigger: Rails/Output: Do not write to stdout. + +puts "Hello from Ruby lib"