diff --git a/package.json b/package.json index a5d4e1f8..09ffd11d 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "python-envs.workspaceSearchPaths": { "type": "array", "description": "%python-envs.workspaceSearchPaths.description%", - "default": [], + "default": ["./**/.venv"], "scope": "resource", "items": { "type": "string" @@ -213,6 +213,12 @@ "category": "Python", "icon": "$(refresh)" }, + { + "command": "python-envs.searchSettings", + "title": "%python-envs.searchSettings.title%", + "category": "Python", + "icon": "$(gear)" + }, { "command": "python-envs.refreshPackages", "title": "%python-envs.refreshPackages.title%", @@ -574,6 +580,11 @@ "group": "navigation", "when": "view == env-managers" }, + { + "command": "python-envs.searchSettings", + "group": "navigation", + "when": "view == env-managers" + }, { "command": "python-envs.refreshAllManagers", "group": "navigation", diff --git a/package.nls.json b/package.nls.json index 7b5b568c..3a4ddcec 100644 --- a/package.nls.json +++ b/package.nls.json @@ -11,8 +11,8 @@ "python-envs.terminal.autoActivationType.shellStartup": "Activation using [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) or by modifying the terminal shell startup script. Enable `terminal.integrated.shellIntegration.enabled` or we may need to modify your shell startup scripts for the ideal experience.", "python-envs.terminal.autoActivationType.off": "No automatic activation of environments.", "python-envs.terminal.useEnvFile.description": "Controls whether environment variables from .env files and python.envFile setting are injected into terminals.", - "python-envs.globalSearchPaths.description": "Global search paths for Python environments. Absolute directory paths that are searched at the user level.\n\n**Legacy Setting Support:** This setting is merged with the legacy `python.venvPath` and `python.venvFolders` settings. All paths from these three settings are combined into a single list of search paths. The legacy settings `python.venvPath` and `python.venvFolders` will be deprecated in the future, after which this setting will fully replace them. Please consider migrating your paths to this setting.", - "python-envs.workspaceSearchPaths.description": "Workspace search paths for Python environments. Can be absolute paths or relative directory paths searched within the workspace.", + "python-envs.globalSearchPaths.description": "Absolute paths to search for Python environments across all workspaces. Use for shared environment folders like `~/envs`.", + "python-envs.workspaceSearchPaths.description": "Paths to search for environments in this workspace. By default, searches for a `.venv` folder in the workspace root.", "python-envs.terminal.revertStartupScriptChanges.title": "Revert Shell Startup Script Changes", "python-envs.reportIssue.title": "Report Issue", "python-envs.setEnvManager.title": "Set Environment Manager", @@ -31,6 +31,7 @@ "python-envs.setEnvSelected.title": "Set!", "python-envs.remove.title": "Delete Environment", "python-envs.refreshAllManagers.title": "Refresh All Environment Managers", + "python-envs.searchSettings.title": "Configure Search Settings", "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", diff --git a/src/extension.ts b/src/extension.ts index 357d035e..0032f79c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,8 @@ -import { commands, ExtensionContext, LogOutputChannel, Terminal, Uri, window } from 'vscode'; -import { version as extensionVersion } from '../package.json'; +import { commands, ExtensionContext, extensions, LogOutputChannel, Terminal, Uri, window } from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from './api'; +import { ENVS_EXTENSION_ID } from './common/constants'; import { ensureCorrectVersion } from './common/extVersion'; -import { registerLogger, traceError, traceInfo, traceVerbose, traceWarn } from './common/logging'; +import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging'; import { clearPersistentState, setPersistentState } from './common/persistentState'; import { newProjectSelection } from './common/pickers/managers'; import { StopWatch } from './common/stopWatch'; @@ -67,6 +67,7 @@ import { TerminalEnvVarInjector } from './features/terminal/terminalEnvVarInject import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; import { registerTerminalPackageWatcher } from './features/terminal/terminalPackageWatcher'; import { getEnvironmentForTerminal } from './features/terminal/utils'; +import { openSearchSettings } from './features/views/envManagerSearch'; import { EnvManagerView } from './features/views/envManagersView'; import { ProjectView } from './features/views/projectView'; import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; @@ -102,8 +103,10 @@ export async function activate(context: ExtensionContext): Promise m.refresh(undefined))); }); }), + commands.registerCommand('python-envs.searchSettings', async () => { + await openSearchSettings(); + }), commands.registerCommand('python-envs.refreshPackages', async (item) => { await refreshPackagesCommand(item, envManagers); }), diff --git a/src/features/views/envManagerSearch.ts b/src/features/views/envManagerSearch.ts new file mode 100644 index 00000000..3d40bea0 --- /dev/null +++ b/src/features/views/envManagerSearch.ts @@ -0,0 +1,11 @@ +import { commands } from 'vscode'; + +/** + * Opens environment search settings at workspace level. + */ +export async function openSearchSettings(): Promise { + await commands.executeCommand( + 'workbench.action.openWorkspaceSettings', + '@ext:ms-python.vscode-python-envs "search path"', + ); +} diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index 9f99bd13..23efc347 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -8,7 +8,7 @@ import { PythonProjectApi } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { ENVS_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../../common/constants'; import { getExtension } from '../../common/extension.apis'; -import { traceError, traceLog, traceWarn } from '../../common/logging'; +import { traceError, traceVerbose, traceWarn } from '../../common/logging'; import { untildify, untildifyArray } from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import { createRunningWorkerPool, WorkerPool } from '../../common/utils/workerPool'; @@ -671,10 +671,23 @@ function getPythonSettingAndUntildify(name: string, scope?: Uri): T | undefin return value; } +/** + * Cross-platform check for absolute paths. + * Uses both current platform's check and Windows-specific check to handle + * Windows paths (e.g., C:\path) when running on Unix systems. + */ +function isAbsolutePath(inputPath: string): boolean { + return path.isAbsolute(inputPath) || path.win32.isAbsolute(inputPath); +} + /** * Gets all extra environment search paths from various configuration sources. * Combines legacy python settings (with migration), globalSearchPaths, and workspaceSearchPaths. - * @returns Array of search directory paths + * + * Paths can include glob patterns which are expanded by the native + * Python Environment Tool (PET) during environment discovery. + * + * @returns Array of search paths (may include glob patterns) */ export async function getAllExtraSearchPaths(): Promise { const searchDirectories: string[] = []; @@ -698,7 +711,7 @@ export async function getAllExtraSearchPaths(): Promise { const trimmedPath = searchPath.trim(); - if (path.isAbsolute(trimmedPath)) { + if (isAbsolutePath(trimmedPath)) { // Absolute path - use as is searchDirectories.push(trimmedPath); } else { @@ -710,20 +723,16 @@ export async function getAllExtraSearchPaths(): Promise { searchDirectories.push(resolvedPath); } } else { - traceWarn('Warning: No workspace folders found for relative path:', trimmedPath); + traceWarn('No workspace folders found for relative search path:', trimmedPath); } } } - // Remove duplicates and return + // Remove duplicates and normalize to forward slashes for cross-platform glob compatibility const uniquePaths = Array.from(new Set(searchDirectories)); - traceLog( - 'getAllExtraSearchPaths completed. Total unique search directories:', - uniquePaths.length, - 'Paths:', - uniquePaths, - ); - return uniquePaths; + const normalizedPaths = uniquePaths.map((p) => p.replace(/\\/g, '/')); + traceVerbose('Environment search directories:', normalizedPaths.length, 'paths'); + return normalizedPaths; } /** @@ -745,6 +754,7 @@ function getGlobalSearchPaths(): string[] { /** * Gets the most specific workspace-level setting available for workspaceSearchPaths. + * Supports glob patterns which are expanded by PET. */ function getWorkspaceSearchPaths(): string[] { try { @@ -753,11 +763,11 @@ function getWorkspaceSearchPaths(): string[] { if (inspection?.globalValue) { traceError( - 'Error: python-envs.workspaceSearchPaths is set at the user/global level, but this setting can only be set at the workspace or workspace folder level.', + 'python-envs.workspaceSearchPaths is set at the user/global level, but this setting can only be set at the workspace or workspace folder level.', ); } - // For workspace settings, prefer workspaceFolder > workspace + // For workspace settings, prefer workspaceFolder > workspace > default if (inspection?.workspaceFolderValue) { return inspection.workspaceFolderValue; } @@ -766,8 +776,8 @@ function getWorkspaceSearchPaths(): string[] { return inspection.workspaceValue; } - // Default empty array (don't use global value for workspace settings) - return []; + // Use the default value from package.json + return inspection?.defaultValue ?? []; } catch (error) { traceError('Error getting workspaceSearchPaths:', error); return []; diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index 19de0841..43a7fe63 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert'; -import path from 'node:path'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; import * as logging from '../../../common/logging'; @@ -32,6 +31,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { mockGetWorkspaceFolders = sinon.stub(workspaceApis, 'getWorkspaceFolders'); mockUntildify = sinon.stub(pathUtils, 'untildify'); // Also stub the namespace import version that might be used by untildifyArray + // Handle both Unix (~/) and Windows-style paths sinon .stub(pathUtils, 'untildifyArray') .callsFake((paths: string[]) => @@ -104,8 +104,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(result, []); }); - test('Legacy and global paths are consolidated', async () => { - // Mock → Legacy paths and globalSearchPaths both exist + test('Legacy and global paths are consolidated (Unix)', async () => { + // Mock → Legacy paths and globalSearchPaths both exist (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); pythonConfig.get.withArgs('venvFolders').returns(['/home/user/venvs']); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -123,8 +123,27 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Legacy paths included alongside new settings', async () => { - // Mock → Legacy paths exist, no globalSearchPaths + test('Legacy and global paths are consolidated (Windows)', async () => { + // Mock → Legacy paths and globalSearchPaths both exist (Windows-style) + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\dev\\.virtualenvs'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\shared\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Users\\dev\\.virtualenvs', 'D:\\shared\\venvs', 'E:\\additional\\path'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Should consolidate all paths (duplicates removed), normalized to forward slashes + const expected = new Set(['C:/Users/dev/.virtualenvs', 'D:/shared/venvs', 'E:/additional/path']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Legacy paths included alongside new settings (Unix)', async () => { + // Mock → Legacy paths exist, no globalSearchPaths (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); pythonConfig.get.withArgs('venvFolders').returns(['/home/user/venvs', '/home/user/conda']); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); @@ -140,6 +159,23 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); + test('Legacy paths included alongside new settings (Windows)', async () => { + // Mock → Legacy paths exist, no globalSearchPaths (Windows-style) + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\dev\\.virtualenvs'); + pythonConfig.get.withArgs('venvFolders').returns(['C:\\Users\\dev\\venvs', 'D:\\conda\\envs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Should include all legacy paths, normalized to forward slashes + const expected = new Set(['C:/Users/dev/.virtualenvs', 'C:/Users/dev/venvs', 'D:/conda/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + test('Legacy and global paths combined with deduplication', async () => { // Mock → Some overlap between legacy and global paths pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); @@ -185,8 +221,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); suite('Configuration Source Tests', () => { - test('Global search paths with tilde expansion', async () => { - // Mock → No legacy, global paths with tildes + test('Global search paths with tilde expansion (Unix)', async () => { + // Mock → No legacy, global paths with tildes (Unix ~ expansion) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -207,14 +243,33 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Workspace folder setting preferred over workspace setting', async () => { - // Mock → Workspace settings at different levels + test('Global search paths with absolute paths (Windows)', async () => { + // Mock → No legacy, global paths with Windows absolute paths + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Users\\dev\\virtualenvs', 'D:\\conda\\envs'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Paths normalized to forward slashes + const expected = new Set(['C:/Users/dev/virtualenvs', 'D:/conda/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Workspace folder setting preferred over workspace setting (Unix)', async () => { + // Mock → Workspace settings at different levels (Unix-style) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceValue: ['workspace-level-path'], - workspaceFolderValue: ['folder-level-path'], + workspaceValue: ['/workspace-level-path'], + workspaceFolderValue: ['/folder-level-path'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -224,11 +279,33 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Use dynamic path construction based on actual workspace URIs - const expected = new Set([ - path.resolve(workspace1.fsPath, 'folder-level-path'), - path.resolve(workspace2.fsPath, 'folder-level-path'), - ]); + // Assert - workspaceFolderValue takes priority, absolute path is kept as-is + const expected = new Set(['/folder-level-path']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Workspace folder setting preferred over workspace setting (Windows)', async () => { + // Mock → Workspace settings at different levels (Windows-style paths in config) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceValue: ['D:\\workspace-level'], + workspaceFolderValue: ['C:\\folder-level\\path'], + }); + + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace1 = Uri.file('/projects/project1'); + const workspace2 = Uri.file('/projects/project2'); + mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - workspaceFolderValue takes priority, normalized to forward slashes + const expected = new Set(['C:/folder-level/path']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -280,8 +357,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); suite('Path Resolution Tests', () => { - test('Absolute paths used as-is', async () => { - // Mock → Mix of absolute paths + test('Absolute paths used as-is (Unix)', async () => { + // Mock → Mix of absolute paths (Unix-style) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -297,20 +374,45 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - For absolute paths, they should remain unchanged regardless of platform + // Assert - For absolute paths, they should remain unchanged const expected = new Set(['/absolute/path1', '/absolute/path2', '/absolute/workspace/path']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Relative paths resolved against workspace folders', async () => { + test('Absolute paths used as-is (Windows)', async () => { + // Mock → Mix of absolute paths (Windows-style paths in config) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\absolute\\path1', 'D:\\absolute\\path2'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['E:\\workspace\\envs'], + }); + + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ uri: workspace }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Windows paths normalized to forward slashes + const expected = new Set(['C:/absolute/path1', 'D:/absolute/path2', 'E:/workspace/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Relative paths are resolved against workspace folders', async () => { // Mock → Relative workspace paths with multiple workspace folders pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['venvs', '../shared-envs'], + workspaceFolderValue: ['venvs', '.venv'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -320,16 +422,13 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - path.resolve() correctly resolves relative paths (order doesn't matter) - const expected = new Set([ - path.resolve(workspace1.fsPath, 'venvs'), - path.resolve(workspace2.fsPath, 'venvs'), - path.resolve(workspace1.fsPath, '../shared-envs'), // Resolves against workspace1 - path.resolve(workspace2.fsPath, '../shared-envs'), // Resolves against workspace2 - ]); - const actual = new Set(result); - assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); - assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + // Assert - Relative paths are resolved against each workspace folder + // path.resolve behavior varies by platform, so check the paths contain expected segments + assert.strictEqual(result.length, 4, 'Should have 4 paths (2 relative × 2 workspaces)'); + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('venvs'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('venvs'))); + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); }); test('Relative paths without workspace folders logs warning', async () => { @@ -346,13 +445,9 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert + // Assert - Path is not added and warning is logged assert.deepStrictEqual(result, []); - // Check that warning was logged with key terms - don't be brittle about exact wording - assert( - mockTraceWarn.calledWith(sinon.match(/workspace.*folder.*relative.*path/i), 'relative-path'), - 'Should log warning about missing workspace folders', - ); + assert.ok(mockTraceWarn.called, 'Should warn about missing workspace folders'); }); test('Empty and whitespace paths are skipped', async () => { @@ -363,7 +458,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { globalValue: ['/valid/path', '', ' ', '/another/valid/path'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['valid-relative', '', ' \t\n ', 'another-valid'], + workspaceFolderValue: ['/workspace/valid', '', ' \t\n ', '/workspace/another'], }); const workspace = Uri.file('/workspace'); @@ -372,13 +467,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Now globalSearchPaths empty strings should be filtered out (order doesn't matter) - const expected = new Set([ - '/valid/path', - '/another/valid/path', - path.resolve(workspace.fsPath, 'valid-relative'), - path.resolve(workspace.fsPath, 'another-valid'), - ]); + // Assert - Empty strings filtered out, valid paths kept + const expected = new Set(['/valid/path', '/another/valid/path', '/workspace/valid', '/workspace/another']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -400,15 +490,15 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(result, []); }); - test('Power user - complex mix of all source types', async () => { - // Mock → Complex real-world scenario + test('Power user - complex mix of all source types (Unix)', async () => { + // Mock → Complex real-world scenario (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/legacy/venv/path'); pythonConfig.get.withArgs('venvFolders').returns(['/legacy/venvs']); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: ['/legacy/venv/path', '/legacy/venvs', '/global/conda', '~/personal/envs'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['.venv', 'project-envs', '/shared/team/envs'], + workspaceFolderValue: ['.venv', '/shared/team/envs'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -420,48 +510,94 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Should deduplicate and combine all sources (order doesn't matter) - const expected = new Set([ - '/legacy/venv/path', - '/legacy/venvs', - '/global/conda', - '/home/user/personal/envs', - path.resolve(workspace1.fsPath, '.venv'), - path.resolve(workspace2.fsPath, '.venv'), - path.resolve(workspace1.fsPath, 'project-envs'), - path.resolve(workspace2.fsPath, 'project-envs'), - '/shared/team/envs', - ]); - const actual = new Set(result); + // Assert - Relative paths are resolved against workspace folders, absolutes kept as-is + assert.ok(result.includes('/legacy/venv/path')); + assert.ok(result.includes('/legacy/venvs')); + assert.ok(result.includes('/global/conda')); + assert.ok(result.includes('/home/user/personal/envs')); + assert.ok(result.includes('/shared/team/envs')); + // .venv resolved against both workspace folders + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); + }); + + test('Power user - complex mix of all source types (Windows)', async () => { + // Mock → Complex real-world scenario (Windows-style paths in config) + pythonConfig.get.withArgs('venvPath').returns('C:\\legacy\\venv\\path'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\legacy\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\legacy\\venv\\path', 'D:\\legacy\\venvs', 'E:\\global\\conda'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['.venv', 'F:\\shared\\team\\envs'], + }); + + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace1 = Uri.file('/workspace/project1'); + const workspace2 = Uri.file('/workspace/project2'); + mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All paths normalized to forward slashes + assert.ok(result.includes('C:/legacy/venv/path')); + assert.ok(result.includes('D:/legacy/venvs')); + assert.ok(result.includes('E:/global/conda')); + assert.ok(result.includes('F:/shared/team/envs')); + // .venv resolved against both workspace folders + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); + // Verify no backslashes remain + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + }); + + test('Overlapping paths are deduplicated (Unix)', async () => { + // Mock → Duplicate paths from different sources (Unix-style) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['/shared/path', '/global/unique'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['/shared/path', '/workspace/unique'], + }); + + const workspace = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ uri: workspace }]); + + // Run + const result = await getAllExtraSearchPaths(); - // Check that we have exactly the expected paths (no more, no less) + // Assert - Duplicates should be removed + const expected = new Set(['/shared/path', '/global/unique', '/workspace/unique']); + const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Overlapping paths are deduplicated', async () => { - // Mock → Duplicate paths from different sources + test('Overlapping paths are deduplicated (Windows)', async () => { + // Mock → Duplicate paths from different sources (Windows-style paths in config) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ - globalValue: ['/shared/path', '/global/unique'], + globalValue: ['C:\\shared\\path', 'D:\\global\\unique'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['/shared/path', 'workspace-unique'], + workspaceFolderValue: ['C:\\shared\\path', 'E:\\workspace\\unique'], }); + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) const workspace = Uri.file('/workspace'); mockGetWorkspaceFolders.returns([{ uri: workspace }]); // Run const result = await getAllExtraSearchPaths(); - // Assert - Duplicates should be removed (order doesn't matter) - const expected = new Set([ - '/shared/path', - '/global/unique', - path.resolve(workspace.fsPath, 'workspace-unique'), - ]); + // Assert - Duplicates should be removed, normalized to forward slashes + const expected = new Set(['C:/shared/path', 'D:/global/unique', 'E:/workspace/unique']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -473,7 +609,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { pythonConfig.get.withArgs('venvFolders').returns(['/legacy/folder']); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: ['/global/path'] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['workspace-relative'], + workspaceFolderValue: ['.venv'], }); const workspace = Uri.file('/workspace'); @@ -482,16 +618,92 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Should consolidate all path types - const expected = new Set([ - '/legacy/path', - '/legacy/folder', - '/global/path', - path.resolve(workspace.fsPath, 'workspace-relative'), - ]); - const actual = new Set(result); - assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); - assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + // Assert - Should consolidate all path types, relative resolved against workspace + assert.ok(result.includes('/legacy/path')); + assert.ok(result.includes('/legacy/folder')); + assert.ok(result.includes('/global/path')); + assert.ok(result.some((p) => p.includes('workspace') && p.endsWith('.venv'))); + }); + }); + + suite('Cross-Platform Path Normalization', () => { + test('Backslashes are converted to forward slashes for glob compatibility', async () => { + // Mock → Windows-style paths with backslashes + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\test\\envs'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\shared\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Python\\environments', 'E:\\projects\\**\\.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All backslashes should be converted to forward slashes + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + assert.ok(result.includes('C:/Users/test/envs')); + assert.ok(result.includes('D:/shared/venvs')); + assert.ok(result.includes('C:/Python/environments')); + assert.ok(result.includes('E:/projects/**/.venv')); + }); + + test('Glob patterns with backslashes are normalized', async () => { + // Mock → Glob pattern with Windows backslashes + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\workspace\\**\\venv', 'D:\\projects\\*\\.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Glob patterns should use forward slashes + assert.ok(result.includes('C:/workspace/**/venv')); + assert.ok(result.includes('D:/projects/*/.venv')); + }); + + test('Linux/macOS paths with forward slashes are preserved', async () => { + // Mock → Unix-style paths (already using forward slashes) + pythonConfig.get.withArgs('venvPath').returns('/home/user/envs'); + pythonConfig.get.withArgs('venvFolders').returns(['/opt/shared/venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['/usr/local/python/environments', '/home/user/projects/**/.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Forward slashes should be preserved as-is + assert.ok(result.includes('/home/user/envs')); + assert.ok(result.includes('/opt/shared/venvs')); + assert.ok(result.includes('/usr/local/python/environments')); + assert.ok(result.includes('/home/user/projects/**/.venv')); + // Verify no backslashes were introduced + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + }); + + test('Mixed path separators are normalized to forward slashes', async () => { + // Mock → Paths with mixed separators (edge case) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:/Users\\test/projects\\.venv', '/home/user\\mixed/path'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All backslashes normalized to forward slashes + assert.ok(result.includes('C:/Users/test/projects/.venv')); + assert.ok(result.includes('/home/user/mixed/path')); }); }); });