diff --git a/contributing.md b/contributing.md index a4d1f4a9..1d67e193 100644 --- a/contributing.md +++ b/contributing.md @@ -19,7 +19,7 @@ source .venv/bin/activate Install then setup with nox. ``` python3 -m pip install nox -nox --session setup_repo +nox ``` ## Reporting Issues diff --git a/package.json b/package.json index 4807aaa9..d52b1564 100644 --- a/package.json +++ b/package.json @@ -256,6 +256,24 @@ "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.", "type": "boolean" }, + "terminalQuoteCharacter": { + "default": null, + "description": "The quoting character to be used by the debugger when quoting terminal commands.", + "type": [ + "string", + "null" + ], + "enum": [ + "\"", + "'", + "`" + ], + "enumDescriptions": [ + "Double quote (\")", + "Single quote (')", + "Backtick (`)" + ] + }, "pathMappings": { "default": [], "items": { @@ -452,6 +470,24 @@ "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.", "type": "boolean" }, + "terminalQuoteCharacter": { + "default": null, + "description": "The quoting character to be used by the debugger when quoting terminal commands.", + "type": [ + "string", + "null" + ], + "enum": [ + "\"", + "'", + "`" + ], + "enumDescriptions": [ + "Double quote (\")", + "Single quote (')", + "Backtick (`)" + ] + }, "module": { "default": "", "description": "Name of the module to be debugged.", diff --git a/src/extension/debugger/configuration/resolvers/launch.ts b/src/extension/debugger/configuration/resolvers/launch.ts index 89efe76a..e991826f 100644 --- a/src/extension/debugger/configuration/resolvers/launch.ts +++ b/src/extension/debugger/configuration/resolvers/launch.ts @@ -105,6 +105,13 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver('integrated.defaultProfile.windows'); + profiles = config.get('integrated.profiles.windows'); + } else if (platform === 'linux') { + defaultProfile = config.get('integrated.defaultProfile.linux'); + profiles = config.get('integrated.profiles.linux'); + } else if (platform === 'darwin') { + defaultProfile = config.get('integrated.defaultProfile.osx'); + profiles = config.get('integrated.profiles.osx'); + } + + if (!defaultProfile || !profiles) { + if (platform === 'win32') { + return "'"; // Default is powershell + } else { + return '"'; // Default is bash/zsh + } + } + + const profile = defaultProfile ? profiles[defaultProfile] : profiles[0]; + const shellPath = profile?.path || ''; + + if (/powershell|pwsh/i.test(shellPath)) { + return "'"; + } + if (/cmd\.exe$/i.test(shellPath)) { + return '"'; + } + if (/bash|zsh|fish/i.test(shellPath)) { + return '"'; + } + + return '"'; + } } diff --git a/src/test/unittest/configuration/resolvers/launch.unit.test.ts b/src/test/unittest/configuration/resolvers/launch.unit.test.ts index 68dd5003..d8b46eda 100644 --- a/src/test/unittest/configuration/resolvers/launch.unit.test.ts +++ b/src/test/unittest/configuration/resolvers/launch.unit.test.ts @@ -52,6 +52,15 @@ getInfoPerOS().forEach(([osName, osType, path]) => { getDebugEnvironmentVariablesStub = sinon.stub(helper, 'getDebugEnvironmentVariables'); getConfigurationStub = sinon.stub(vscodeapi, 'getConfiguration'); getConfigurationStub.withArgs('debugpy', sinon.match.any).returns(createMoqConfiguration(true)); + // Mock python configuration for useEnvExtension check + const pythonConfig = TypeMoq.Mock.ofType(); + pythonConfig.setup((p) => p.get('useEnvironmentsExtension', false)).returns(() => false); + getConfigurationStub.withArgs('python').returns(pythonConfig.object); + // Mock terminal configuration with default values + const terminalConfig = TypeMoq.Mock.ofType(); + terminalConfig.setup((c) => c.get(TypeMoq.It.isAnyString())).returns(() => undefined); + terminalConfig.setup((c) => c.get(TypeMoq.It.isAnyString())).returns(() => undefined); + getConfigurationStub.withArgs('terminal').returns(terminalConfig.object); }); teardown(() => { @@ -1045,5 +1054,228 @@ getInfoPerOS().forEach(([osName, osType, path]) => { await testSetting(requestType, { subProcess: true }, DebugOptions.SubProcess, true); }); }); + + suite('terminalQuoteCharacter tests', () => { + let platformStub: sinon.SinonStub; + + function setupTerminalProfile( + profile: string | undefined, + path: string = 'bash', + platform: NodeJS.Platform = 'linux', + ) { + const terminalConfig = TypeMoq.Mock.ofType(); + + // Mock process.platform + if (platformStub) { + platformStub.restore(); + } + platformStub = sinon.stub(process, 'platform').value(platform); + + if (platform === 'win32') { + terminalConfig + .setup((c) => c.get('integrated.defaultProfile.windows')) + .returns(() => profile); + const profiles: any = {}; + if (profile) { + profiles[profile] = { path }; + } + terminalConfig.setup((c) => c.get('integrated.profiles.windows')).returns(() => profiles); + } else if (platform === 'linux') { + terminalConfig + .setup((c) => c.get('integrated.defaultProfile.linux')) + .returns(() => profile); + const profiles: any = {}; + if (profile) { + profiles[profile] = { path }; + } + terminalConfig.setup((c) => c.get('integrated.profiles.linux')).returns(() => profiles); + } else if (platform === 'darwin') { + terminalConfig.setup((c) => c.get('integrated.defaultProfile.osx')).returns(() => profile); + const profiles: any = {}; + if (profile) { + profiles[profile] = { path }; + } + terminalConfig.setup((c) => c.get('integrated.profiles.osx')).returns(() => profiles); + } + + getConfigurationStub.withArgs('terminal').returns(terminalConfig.object); + } + + test('Default terminalQuoteCharacter is computed for bash shell', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile('Bash', '/bin/bash', 'linux'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', '"'); + }); + + test('Default terminalQuoteCharacter is computed for zsh shell', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile('Zsh', '/bin/zsh', 'darwin'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', '"'); + }); + + test('Default terminalQuoteCharacter is computed for fish shell', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile('Fish', '/usr/bin/fish', 'linux'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', '"'); + }); + + test('Default terminalQuoteCharacter is computed for PowerShell', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile( + 'PowerShell', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'win32', + ); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', "'"); + }); + + test('Default terminalQuoteCharacter is computed for cmd.exe', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile('Command Prompt', 'C:\\Windows\\System32\\cmd.exe', 'win32'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', '"'); + }); + + test('Default terminalQuoteCharacter is double quote when terminal profile is not configured', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile(undefined, 'bash', 'linux'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', '"'); + }); + + test('User-provided terminalQuoteCharacter is preserved when valid (single character)', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile('Bash', '/bin/bash', 'linux'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + terminalQuoteCharacter: "'", + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', "'"); + }); + + test('Invalid terminalQuoteCharacter (empty string) triggers fallback to computed default', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile('PowerShell', 'powershell.exe', 'win32'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + terminalQuoteCharacter: '', + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', "'"); + }); + + test('Invalid terminalQuoteCharacter (multi-character) triggers fallback to computed default', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile('Bash', '/bin/bash', 'linux'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + terminalQuoteCharacter: '""', + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', '"'); + }); + + test('terminalQuoteCharacter handles pwsh (PowerShell Core)', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile('PowerShell Core', '/usr/local/bin/pwsh', 'darwin'); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', "'"); + }); + + test('terminalQuoteCharacter is case-insensitive for shell detection', async () => { + const pythonPath = `PythonPath_${new Date().toString()}`; + const workspaceFolder = createMoqWorkspaceFolder(__dirname); + const pythonFile = 'xyz.py'; + setupIoc(pythonPath, workspaceFolder); + setupActiveEditor(pythonFile, PYTHON_LANGUAGE); + setupTerminalProfile( + 'PowerShell', + 'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\POWERSHELL.EXE', + 'win32', + ); + + const debugConfig = await resolveDebugConfiguration(workspaceFolder, { + ...launch, + }); + + expect(debugConfig).to.have.property('terminalQuoteCharacter', "'"); + }); + }); }); });