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
+ }
+ })
+})
+