diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d59384..3ed449de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Add `cucumber.unescapeBackslashes` configuration option to unescape backslashes in Cucumber Expression patterns from JavaScript step definitions + ## [1.11.0] - 2025-05-18 ### Changed - Update dependency @cucumber/language-server to 1.7.0 diff --git a/README.md b/README.md index f62059dc..da11bcee 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,29 @@ For example, if you're using the `actor` parameter type from [@cucumber/screenpl [//]: # () +### `cucumber.unescapeBackslashes` + +[//]: # () +Enable the `cucumber.unescapeBackslashes` setting if you need to unescape backslashes +in Cucumber Expression patterns from JavaScript or TypeScript step definitions. + +In Cucumber Expressions, you need to escape a forward slash with a backslash (`\/`) for +[alternative text behavior](https://github.com/cucumber/cucumber-expressions?tab=readme-ov-file#alternative-text). +In JavaScript source code, backslashes themselves must be escaped, so you write `"\\/"` +to achieve `\/` at runtime. However, sometimes the library interprets `\\/` as `\\/` +(two backslashes) instead of `\/` (one backslash). This setting converts escaped +backslashes (`\\`) back to regular backslashes (`\`) for correct interpretation. + +Default value: + +```json +{ + "cucumber.unescapeBackslashes": false +} +``` + +[//]: # () + ## Feedback If you discover a bug, or have a suggestion for a feature request, please diff --git a/package.json b/package.json index 70a74ff1..06f57288 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,12 @@ "type": "array", "required": false, "default": [] + }, + "cucumber.unescapeBackslashes": { + "markdownDescription": "Enable the `cucumber.unescapeBackslashes` setting if you need to unescape backslashes\nin Cucumber Expression patterns from JavaScript or TypeScript step definitions.\n\nIn Cucumber Expressions, you need to escape a forward slash with a backslash (`\/`) for\n[alternative text behavior](https://github.com/cucumber/cucumber-expressions?tab=readme-ov-file#alternative-text).\nIn JavaScript source code, backslashes themselves must be escaped, so you write `\"\\/\"`\nto achieve `\/` at runtime. However, sometimes the library interprets `\\/` as `\\/`\n(two backslashes) instead of `\/` (one backslash). This setting converts escaped\nbackslashes (`\\\\`) back to regular backslashes (`\\`) for correct interpretation.\n\nDefault value:\n\n```json\n{\n \"cucumber.unescapeBackslashes\": false\n}\n```", + "type": "boolean", + "required": false, + "default": false } } } diff --git a/scripts/update-settings-docs.sh b/scripts/update-settings-docs.sh index e118465b..6b029d57 100755 --- a/scripts/update-settings-docs.sh +++ b/scripts/update-settings-docs.sh @@ -63,8 +63,10 @@ updateMarkdownDescription "cucumber.glue" updateResult2=$? updateMarkdownDescription "cucumber.parameterTypes" updateResult3=$? +updateMarkdownDescription "cucumber.unescapeBackslashes" +updateResult4=$? -if [ "$updateResult1" -eq 1 ] || [ "$updateResult2" -eq 1 ] || [ "$updateResult3" -eq 1 ]; then +if [ "$updateResult1" -eq 1 ] || [ "$updateResult2" -eq 1 ] || [ "$updateResult3" -eq 1 ] || [ "$updateResult4" -eq 1 ]; then echo "The settings descriptions and default values in 'package.json' do not match those specified in 'README.md'. Updating 'package.json' to match." exit 1 else diff --git a/src/VscodeFiles.ts b/src/VscodeFiles.ts index f725c602..06e3a1f9 100644 --- a/src/VscodeFiles.ts +++ b/src/VscodeFiles.ts @@ -4,6 +4,17 @@ import { FileSystem, Uri, workspace } from 'vscode' export class VscodeFiles implements Files { constructor(private readonly fs: FileSystem) {} + private shouldUnescapeBackslashes(): boolean { + return workspace.getConfiguration('cucumber').get('unescapeBackslashes', false) + } + + private unescapeBackslashesInContent(content: string): string { + if (this.shouldUnescapeBackslashes()) { + return content.replace(/\\\\/g, '\\') + } + return content + } + async exists(uri: string): Promise { try { await this.fs.stat(Uri.parse(uri)) @@ -15,7 +26,8 @@ export class VscodeFiles implements Files { async readFile(uri: string): Promise { const data = await this.fs.readFile(Uri.parse(uri)) - return new TextDecoder().decode(data) + const content = new TextDecoder().decode(data) + return this.unescapeBackslashesInContent(content) } async findUris(glob: string): Promise { diff --git a/src/test/suite/VscodeFiles.test.ts b/src/test/suite/VscodeFiles.test.ts new file mode 100644 index 00000000..086652cf --- /dev/null +++ b/src/test/suite/VscodeFiles.test.ts @@ -0,0 +1,216 @@ +import assert from 'assert' +import * as vscode from 'vscode' +import { VscodeFiles } from '../../VscodeFiles' + +suite('VscodeFiles Test Suite', () => { + let mockFileSystem: vscode.FileSystem + let testContent: Uint8Array + + setup(() => { + // Create a mock file system + mockFileSystem = vscode.workspace.fs + // Create test content with escaped backslashes + testContent = new TextEncoder().encode('const pattern = "\\\\/"') + }) + + test('readFile should not unescape backslashes when config is disabled (default)', async () => { + // Mock workspace configuration to return false (default) + const originalGetConfiguration = vscode.workspace.getConfiguration + const mockGetConfiguration = () => ({ + get: (key: string, defaultValue: T): T => { + if (key === 'unescapeBackslashes') { + return false as T + } + return defaultValue + }, + }) as vscode.WorkspaceConfiguration + + // Temporarily replace getConfiguration + ;(vscode.workspace as any).getConfiguration = mockGetConfiguration + + try { + const vscodeFiles = new VscodeFiles(mockFileSystem) + + // Create a temporary file with escaped backslashes + const testUri = vscode.Uri.file( + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + '/test-file.js' || '/test-file.js' + ) + + // Write test content + await vscode.workspace.fs.writeFile(testUri, testContent) + + // Read the file + const content = await vscodeFiles.readFile(testUri.toString()) + + // Content should remain unchanged (backslashes still escaped) + assert.strictEqual( + content.includes('\\\\/'), + true, + 'Content should contain escaped backslashes when config is disabled' + ) + + // Clean up + await vscode.workspace.fs.delete(testUri) + } finally { + // Restore original getConfiguration + ;(vscode.workspace as any).getConfiguration = originalGetConfiguration + } + }) + + test('readFile should unescape backslashes when config is enabled', async () => { + // Mock workspace configuration to return true + const originalGetConfiguration = vscode.workspace.getConfiguration + const mockGetConfiguration = () => ({ + get: (key: string, defaultValue: T): T => { + if (key === 'unescapeBackslashes') { + return true as T + } + return defaultValue + }, + }) as vscode.WorkspaceConfiguration + + // Temporarily replace getConfiguration + ;(vscode.workspace as any).getConfiguration = mockGetConfiguration + + try { + const vscodeFiles = new VscodeFiles(mockFileSystem) + + // Create a temporary file with escaped backslashes + const testUri = vscode.Uri.file( + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + '/test-file.js' || '/test-file.js' + ) + + // Write test content with escaped backslashes + const contentWithEscaped = new TextEncoder().encode('const pattern = "\\\\/"') + await vscode.workspace.fs.writeFile(testUri, contentWithEscaped) + + // Read the file + const content = await vscodeFiles.readFile(testUri.toString()) + + // Content should have backslashes unescaped + assert.strictEqual( + content.includes('\\/'), + true, + 'Content should contain unescaped backslash when config is enabled' + ) + assert.strictEqual( + content.includes('\\\\/'), + false, + 'Content should not contain escaped backslashes when config is enabled' + ) + + // Clean up + await vscode.workspace.fs.delete(testUri) + } finally { + // Restore original getConfiguration + ;(vscode.workspace as any).getConfiguration = originalGetConfiguration + } + }) + + test('readFile should handle multiple escaped backslashes', async () => { + // Mock workspace configuration to return true + const originalGetConfiguration = vscode.workspace.getConfiguration + const mockGetConfiguration = () => ({ + get: (key: string, defaultValue: T): T => { + if (key === 'unescapeBackslashes') { + return true as T + } + return defaultValue + }, + }) as vscode.WorkspaceConfiguration + + ;(vscode.workspace as any).getConfiguration = mockGetConfiguration + + try { + const vscodeFiles = new VscodeFiles(mockFileSystem) + + const testUri = vscode.Uri.file( + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + '/test-file.js' || '/test-file.js' + ) + + // Write test content with multiple escaped backslashes + const contentWithMultipleEscaped = new TextEncoder().encode( + 'const pattern1 = "\\\\/"; const pattern2 = "\\\\test\\\\path"' + ) + await vscode.workspace.fs.writeFile(testUri, contentWithMultipleEscaped) + + const content = await vscodeFiles.readFile(testUri.toString()) + + // All escaped backslashes should be unescaped + assert.strictEqual( + content.includes('\\/'), + true, + 'Should unescape backslashes in pattern1' + ) + assert.strictEqual( + content.includes('\\test\\path'), + true, + 'Should unescape backslashes in pattern2' + ) + assert.strictEqual( + content.includes('\\\\'), + false, + 'Should not contain any escaped backslashes' + ) + + await vscode.workspace.fs.delete(testUri) + } finally { + ;(vscode.workspace as any).getConfiguration = originalGetConfiguration + } + }) + + test('readFile should read config dynamically on each call', async () => { + const originalGetConfiguration = vscode.workspace.getConfiguration + let configValue = false + + const mockGetConfiguration = () => ({ + get: (key: string, defaultValue: T): T => { + if (key === 'unescapeBackslashes') { + return configValue as T + } + return defaultValue + }, + }) as vscode.WorkspaceConfiguration + + ;(vscode.workspace as any).getConfiguration = mockGetConfiguration + + try { + const vscodeFiles = new VscodeFiles(mockFileSystem) + + const testUri = vscode.Uri.file( + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + '/test-file.js' || '/test-file.js' + ) + + const contentWithEscaped = new TextEncoder().encode('const pattern = "\\\\/"') + await vscode.workspace.fs.writeFile(testUri, contentWithEscaped) + + // First read with config disabled + configValue = false + let content = await vscodeFiles.readFile(testUri.toString()) + assert.strictEqual( + content.includes('\\\\/'), + true, + 'Should not unescape when config is false' + ) + + // Second read with config enabled (simulating config change) + configValue = true + content = await vscodeFiles.readFile(testUri.toString()) + assert.strictEqual( + content.includes('\\/'), + true, + 'Should unescape when config is true' + ) + assert.strictEqual( + content.includes('\\\\/'), + false, + 'Should not contain escaped backslashes when config is true' + ) + + await vscode.workspace.fs.delete(testUri) + } finally { + ;(vscode.workspace as any).getConfiguration = originalGetConfiguration + } + }) +}) +