├── .prettierignore ├── .husky └── pre-commit ├── package.cjs.json ├── src ├── node │ ├── index.ts │ └── NodeFiles.ts ├── wasm │ ├── index.ts │ ├── startStandaloneServer.ts │ └── startEmbeddedServer.ts ├── index.ts ├── types.ts ├── Files.ts ├── buildStepTexts.ts ├── getStepDefinitionSnippetLinks.ts ├── fs.ts └── CucumberLanguageServer.ts ├── .mocharc.json ├── testdata ├── gherkin │ ├── five.feature │ └── eight.feature ├── javascript │ ├── stepdefs.js │ ├── stepdefs.cjs │ └── stepdefs.mjs └── typescript │ ├── stepdefs.cts │ ├── stepdefs.mts │ └── stepdefs.ts ├── .prettierrc.json ├── RELEASING.md ├── tsconfig.build-esm.json ├── tsconfig.build-cjs.json ├── .github ├── renovate.json └── workflows │ ├── release-github.yml │ ├── release-npm.yml │ └── test-javascript.yml ├── node └── package.json ├── wasm └── package.json ├── .gitignore ├── tsconfig.build.json ├── CONTRIBUTING.md ├── tsconfig.json ├── bin └── cucumber-language-server.cjs ├── LICENSE ├── .eslintrc.json ├── test ├── getStepDefinitionSnippetLinks.test.ts ├── standalone.test.ts └── CucumberLanguageServer.test.ts ├── README.md ├── package.json └── CHANGELOG.md /.prettierignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /package.cjs.json: -------------------------------------------------------------------------------- 1 | {"type": "commonjs"} 2 | -------------------------------------------------------------------------------- /src/node/index.ts: -------------------------------------------------------------------------------- 1 | export * from './NodeFiles.js' 2 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "loader": "ts-node/esm", 3 | "extension": ["ts"], 4 | "recursive": true 5 | } 6 | -------------------------------------------------------------------------------- /testdata/gherkin/five.feature: -------------------------------------------------------------------------------- 1 | Feature: Five 2 | Scenario: Five cukes 3 | Given I have 5 cukes 4 | 5 | -------------------------------------------------------------------------------- /src/wasm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './startEmbeddedServer.js' 2 | export * from './startStandaloneServer.js' 3 | -------------------------------------------------------------------------------- /testdata/gherkin/eight.feature: -------------------------------------------------------------------------------- 1 | Feature: Eight 2 | Scenario: Eight cukes 3 | Given I have 8 cukes 4 | 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CucumberLanguageServer.js' 2 | export * from './Files.js' 3 | export { CucumberExpressions, Suggestion } from '@cucumber/language-service' 4 | -------------------------------------------------------------------------------- /testdata/javascript/stepdefs.js: -------------------------------------------------------------------------------- 1 | import { Given } from '@cucumber/cucumber' 2 | import assert from 'assert' 3 | 4 | Given('I have {int} cukes', (count) => assert(count)) 5 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Update `src/version.ts` with the version you're about to release. 2 | 3 | See [.github/RELEASING](https://github.com/cucumber/.github/blob/main/RELEASING.md). 4 | -------------------------------------------------------------------------------- /testdata/javascript/stepdefs.cjs: -------------------------------------------------------------------------------- 1 | import { Given } from '@cucumber/cucumber' 2 | import assert from 'assert' 3 | 4 | Given('I have {int} cukes', (count) => assert(count)) 5 | -------------------------------------------------------------------------------- /testdata/javascript/stepdefs.mjs: -------------------------------------------------------------------------------- 1 | import { Given } from '@cucumber/cucumber' 2 | import assert from 'assert' 3 | 4 | Given('I have {int} cukes', (count) => assert(count)) 5 | -------------------------------------------------------------------------------- /testdata/typescript/stepdefs.cts: -------------------------------------------------------------------------------- 1 | import { Given } from '@cucumber/cucumber' 2 | import assert from 'assert' 3 | 4 | Given('I have {int} cukes', (count: number) => assert(count)) 5 | -------------------------------------------------------------------------------- /testdata/typescript/stepdefs.mts: -------------------------------------------------------------------------------- 1 | import { Given } from '@cucumber/cucumber' 2 | import assert from 'assert' 3 | 4 | Given('I have {int} cukes', (count: number) => assert(count)) 5 | -------------------------------------------------------------------------------- /testdata/typescript/stepdefs.ts: -------------------------------------------------------------------------------- 1 | import { Given } from '@cucumber/cucumber' 2 | import assert from 'assert' 3 | 4 | Given('I have {int} cukes', (count: number) => assert(count)) 5 | -------------------------------------------------------------------------------- /tsconfig.build-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "target": "ES6", 5 | "module": "ES6", 6 | "outDir": "dist/esm" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "target": "ES6", 5 | "module": "CommonJS", 6 | "outDir": "dist/cjs" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>cucumber/renovate-config", 5 | ":automergeMajor" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wasm", 3 | "description": "Cucumber Language server using tree-sitter node bindings", 4 | "main": "../dist/cjs/src/node/index.js", 5 | "module": "../dist/esm/src/node/index.js", 6 | "types": "../dist/esm/src/node/index.d.ts" 7 | } 8 | -------------------------------------------------------------------------------- /wasm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wasm", 3 | "description": "Cucumber Language server using tree-sitter wasm bindings", 4 | "main": "../dist/cjs/src/wasm/index.js", 5 | "module": "../dist/esm/src/wasm/index.js", 6 | "types": "../dist/esm/src/wasm/index.d.ts" 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .idea/ 3 | .nyc_output/ 4 | coverage/ 5 | node_modules/ 6 | yarn.lock 7 | *.log 8 | .deps 9 | .tested* 10 | .linted 11 | .built* 12 | .compared 13 | .codegen 14 | acceptance/ 15 | storybook-static 16 | *-go 17 | *.iml 18 | .vscode-test 19 | src/version.ts 20 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | "rootDir": ".", 9 | "noEmit": false, 10 | "lib": ["ES2019"] 11 | }, 12 | "include": ["src", "test"] 13 | } 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for contributing! Please follow these steps before preparing a pull request 4 | 5 | ## Run the tests 6 | 7 | npm install 8 | npm test 9 | 10 | ## Manual testing 11 | 12 | If you need to test your changes in an editor, use VSCode. 13 | See [cucumber/vscode/CONTRIBUTING.md](https://github.com/cucumber/vscode/blob/main/CONTRIBUTING.md) for details. 14 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { LanguageName } from '@cucumber/language-service' 2 | 3 | export type ParameterTypeMeta = { name: string; regexp: string } 4 | 5 | /** 6 | * This structure represents settings provided by the LSP client. 7 | */ 8 | export type Settings = { 9 | features: readonly string[] 10 | glue: readonly string[] 11 | parameterTypes: readonly ParameterTypeMeta[] 12 | snippetTemplates: Readonly>> 13 | } 14 | -------------------------------------------------------------------------------- /src/Files.ts: -------------------------------------------------------------------------------- 1 | export interface Files { 2 | exists(uri: string): Promise 3 | readFile(uri: string): Promise 4 | findUris(glob: string): Promise 5 | relativePath(uri: string): string 6 | } 7 | 8 | export function extname(uri: string): string { 9 | // Roughly-enough implements https://nodejs.org/dist/latest-v18.x/docs/api/path.html#pathextnamepath 10 | return uri.substring(uri.lastIndexOf('.'), uri.length) || '' 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/release-github.yml: -------------------------------------------------------------------------------- 1 | name: Release GitHub 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | jobs: 8 | create-github-release: 9 | name: Create GitHub Release and Git tag 10 | runs-on: ubuntu-latest 11 | environment: Release 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: cucumber/action-create-github-release@v1.1.1 17 | with: 18 | github-token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /src/buildStepTexts.ts: -------------------------------------------------------------------------------- 1 | import { walkGherkinDocument } from '@cucumber/gherkin-utils' 2 | import { parseGherkinDocument } from '@cucumber/language-service' 3 | 4 | export function buildStepTexts(gherkinSource: string): readonly string[] { 5 | const { gherkinDocument } = parseGherkinDocument(gherkinSource) 6 | if (!gherkinDocument) { 7 | return [] 8 | } 9 | const stepTexts: string[] = [] 10 | walkGherkinDocument(gherkinDocument, undefined, { 11 | step(step) { 12 | stepTexts.push(step.text) 13 | }, 14 | }) 15 | return stepTexts 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "declaration": true, 5 | "sourceMap": true, 6 | "allowJs": false, 7 | "resolveJsonModule": true, 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "downlevelIteration": true, 11 | "skipLibCheck": true, 12 | "strictNullChecks": true, 13 | "experimentalDecorators": true, 14 | "module": "ESNext", 15 | "lib": ["ES6", "dom"], 16 | "target": "ES6", 17 | "moduleResolution": "node", 18 | "allowSyntheticDefaultImports": true, 19 | "noEmit": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/release-npm.yml: -------------------------------------------------------------------------------- 1 | name: Release NPM 2 | 3 | on: 4 | push: 5 | branches: [release/*] 6 | 7 | permissions: 8 | id-token: write 9 | contents: read 10 | 11 | jobs: 12 | publish-npm: 13 | name: Publish NPM module 14 | runs-on: ubuntu-latest 15 | environment: Release 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: actions/setup-node@v6 19 | with: 20 | node-version: '24.x' 21 | cache: 'npm' 22 | cache-dependency-path: package-lock.json 23 | registry-url: 'https://registry.npmjs.org' 24 | - run: npm install-ci-test 25 | - run: npm publish 26 | -------------------------------------------------------------------------------- /bin/cucumber-language-server.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | require('source-map-support').install() 4 | const { startStandaloneServer } = require('../dist/cjs/src/wasm/startStandaloneServer') 5 | const { NodeFiles } = require('../dist/cjs/src/node/NodeFiles') 6 | const path = require('path') 7 | const { version } = require('../dist/cjs/src/version') 8 | 9 | const wasmBasePath = path.resolve(`${__dirname}/../node_modules/@cucumber/language-service/dist`) 10 | const { connection } = startStandaloneServer(wasmBasePath, (rootUri) => new NodeFiles(rootUri)) 11 | 12 | // Don't die on unhandled Promise rejections 13 | process.on('unhandledRejection', (reason, p) => { 14 | connection.console.error( 15 | `Cucumber Language Server ${version}: Unhandled Rejection at promise: ${p}, reason: ${reason}` 16 | ) 17 | }) 18 | -------------------------------------------------------------------------------- /src/wasm/startStandaloneServer.ts: -------------------------------------------------------------------------------- 1 | import { WasmParserAdapter } from '@cucumber/language-service/wasm' 2 | import { TextDocuments } from 'vscode-languageserver' 3 | import { createConnection, ProposedFeatures } from 'vscode-languageserver/node' 4 | import { TextDocument } from 'vscode-languageserver-textdocument' 5 | 6 | import { CucumberLanguageServer } from '../CucumberLanguageServer' 7 | import { Files } from '../Files' 8 | 9 | export function startStandaloneServer(wasmBaseUrl: string, makeFiles: (rootUri: string) => Files) { 10 | const adapter = new WasmParserAdapter(wasmBaseUrl) 11 | const connection = createConnection(ProposedFeatures.all) 12 | const documents = new TextDocuments(TextDocument) 13 | new CucumberLanguageServer(connection, documents, adapter, makeFiles, () => undefined) 14 | connection.listen() 15 | 16 | return { 17 | connection, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/getStepDefinitionSnippetLinks.ts: -------------------------------------------------------------------------------- 1 | import { LocationLink, Range } from 'vscode-languageserver-types' 2 | 3 | export function getStepDefinitionSnippetLinks( 4 | links: readonly LocationLink[] 5 | ): readonly LocationLink[] { 6 | // TODO: Find the most relevant one by looking at the words in the expression 7 | // If none found, use the most recently modified 8 | const linksByFile: Record = {} 9 | for (const link of links) { 10 | // Insert right after the last step definition 11 | 12 | const targetRange = Range.create( 13 | link.targetRange.end.line + 1, 14 | 0, 15 | link.targetRange.end.line + 1, 16 | 0 17 | ) 18 | 19 | linksByFile[link.targetUri] = { 20 | targetUri: link.targetUri, 21 | targetRange, 22 | targetSelectionRange: targetRange, 23 | } 24 | } 25 | 26 | return Object.values(linksByFile).sort((a, b) => a.targetUri.localeCompare(b.targetUri)) 27 | } 28 | -------------------------------------------------------------------------------- /src/node/NodeFiles.ts: -------------------------------------------------------------------------------- 1 | import fg from 'fast-glob' 2 | import fs from 'fs/promises' 3 | import { relative } from 'path' 4 | import url from 'url' 5 | 6 | import { Files } from '../Files.js' 7 | 8 | export class NodeFiles implements Files { 9 | constructor(private readonly rootUri: string) {} 10 | 11 | async exists(uri: string): Promise { 12 | try { 13 | await fs.stat(new URL(uri)) 14 | return true 15 | } catch { 16 | return false 17 | } 18 | } 19 | 20 | readFile(uri: string): Promise { 21 | const path = url.fileURLToPath(uri) 22 | return fs.readFile(path, 'utf-8') 23 | } 24 | 25 | async findUris(glob: string): Promise { 26 | const cwd = url.fileURLToPath(this.rootUri) 27 | const paths = await fg(glob, { cwd, caseSensitiveMatch: false, onlyFiles: true }) 28 | return paths.map((path) => url.pathToFileURL(path).href) 29 | } 30 | 31 | relativePath(uri: string): string { 32 | return relative(new URL(this.rootUri).pathname, new URL(uri).pathname) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Cucumber Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/test-javascript.yml: -------------------------------------------------------------------------------- 1 | name: test-javascript 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | workflow_call: 8 | push: 9 | branches: 10 | - main 11 | - renovate/** 12 | 13 | jobs: 14 | test-javascript: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: 20 | - macos-latest 21 | - ubuntu-latest 22 | - windows-latest 23 | node-version: ['24.x'] 24 | 25 | steps: 26 | - name: set git core.autocrlf to 'input' 27 | run: git config --global core.autocrlf input 28 | - uses: actions/checkout@v6 29 | - name: with Node.js ${{ matrix.node-version }} on ${{ matrix.os }} 30 | uses: actions/setup-node@v6 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | cache: 'npm' 34 | cache-dependency-path: package-lock.json 35 | - run: npm install 36 | if: ${{ matrix.os != 'windows-latest' }} 37 | - run: npm install --no-optional 38 | if: ${{ matrix.os == 'windows-latest' }} 39 | - run: npm test 40 | - run: npm run eslint 41 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "parserOptions": { 8 | "project": "tsconfig.json", 9 | "sourceType": "module" 10 | }, 11 | "plugins": ["import", "simple-import-sort", "n", "@typescript-eslint"], 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:import/typescript", 15 | "plugin:@typescript-eslint/eslint-recommended", 16 | "plugin:@typescript-eslint/recommended", 17 | "plugin:prettier/recommended" 18 | ], 19 | "rules": { 20 | "import/no-cycle": "error", 21 | "n/no-extraneous-import": "error", 22 | "@typescript-eslint/ban-ts-ignore": "off", 23 | "@typescript-eslint/ban-ts-comment": "off", 24 | "@typescript-eslint/explicit-module-boundary-types": "off", 25 | "@typescript-eslint/explicit-function-return-type": "off", 26 | "@typescript-eslint/no-use-before-define": "off", 27 | "@typescript-eslint/interface-name-prefix": "off", 28 | "@typescript-eslint/member-delimiter-style": "off", 29 | "@typescript-eslint/no-explicit-any": "error", 30 | "@typescript-eslint/no-non-null-assertion": "error", 31 | "simple-import-sort/imports": "error", 32 | "simple-import-sort/exports": "error" 33 | }, 34 | "overrides": [ 35 | { 36 | "files": ["test/**"], 37 | "rules": { 38 | "@typescript-eslint/no-non-null-assertion": "off" 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/wasm/startEmbeddedServer.ts: -------------------------------------------------------------------------------- 1 | import { CucumberExpressions, ParserAdapter, Suggestion } from '@cucumber/language-service' 2 | import { WasmParserAdapter } from '@cucumber/language-service/wasm' 3 | import { PassThrough } from 'stream' 4 | import { Connection, TextDocuments } from 'vscode-languageserver' 5 | import { createConnection } from 'vscode-languageserver/node' 6 | import { TextDocument } from 'vscode-languageserver-textdocument' 7 | 8 | import { CucumberLanguageServer } from '../CucumberLanguageServer.js' 9 | import { Files } from '../Files.js' 10 | 11 | export type ServerInfo = { 12 | writer: NodeJS.WritableStream 13 | reader: NodeJS.ReadableStream 14 | server: CucumberLanguageServer 15 | connection: Connection 16 | } 17 | 18 | export function startEmbeddedServer( 19 | wasmBaseUrl: string, 20 | makeFiles: (rootUri: string) => Files, 21 | onReindexed: ( 22 | registry: CucumberExpressions.ParameterTypeRegistry, 23 | expressions: readonly CucumberExpressions.Expression[], 24 | suggestions: readonly Suggestion[] 25 | ) => void 26 | ): ServerInfo { 27 | const adapter: ParserAdapter = new WasmParserAdapter(wasmBaseUrl) 28 | const inputStream = new PassThrough() 29 | const outputStream = new PassThrough() 30 | 31 | const connection = createConnection(inputStream, outputStream) 32 | const documents = new TextDocuments(TextDocument) 33 | const server = new CucumberLanguageServer(connection, documents, adapter, makeFiles, onReindexed) 34 | connection.listen() 35 | 36 | return { 37 | writer: inputStream, 38 | reader: outputStream, 39 | server, 40 | connection, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/getStepDefinitionSnippetLinks.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { LocationLink, Range } from 'vscode-languageserver-types' 3 | 4 | import { getStepDefinitionSnippetLinks } from '../src/getStepDefinitionSnippetLinks.js' 5 | 6 | describe('guessStepDefinitionSnippetPath', () => { 7 | it('creates a location 2 lines below the first link', async () => { 8 | const targetRangeA1 = Range.create(10, 0, 20, 14) 9 | const targetRangeA2 = Range.create(30, 0, 40, 14) 10 | const targetRangeB = Range.create(25, 0, 35, 14) 11 | const links: LocationLink[] = [ 12 | { 13 | targetUri: 'file://home/testdata/typescript/a.ts', 14 | targetRange: targetRangeA2, 15 | targetSelectionRange: targetRangeA2, 16 | }, 17 | { 18 | targetUri: 'file://home/testdata/typescript/b.ts', 19 | targetRange: targetRangeB, 20 | targetSelectionRange: targetRangeB, 21 | }, 22 | { 23 | targetUri: 'file://home/testdata/typescript/a.ts', 24 | targetRange: targetRangeA1, 25 | targetSelectionRange: targetRangeA1, 26 | }, 27 | ] 28 | 29 | const expectedRange1 = Range.create(21, 0, 21, 0) 30 | const expectedRange2 = Range.create(36, 0, 36, 0) 31 | const expected: LocationLink[] = [ 32 | { 33 | targetUri: 'file://home/testdata/typescript/a.ts', 34 | targetRange: expectedRange1, 35 | targetSelectionRange: expectedRange1, 36 | }, 37 | { 38 | targetUri: 'file://home/testdata/typescript/b.ts', 39 | targetRange: expectedRange2, 40 | targetSelectionRange: expectedRange2, 41 | }, 42 | ] 43 | const result = getStepDefinitionSnippetLinks(links) 44 | assert.deepStrictEqual(result, expected) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/fs.ts: -------------------------------------------------------------------------------- 1 | import { LanguageName, Source } from '@cucumber/language-service' 2 | 3 | import { extname, Files } from './Files.js' 4 | 5 | export const glueExtByLanguageName: Record = { 6 | javascript: ['.js', '.cjs', '.mjs', '.jsx'], 7 | tsx: ['.ts', '.cts', '.mts', '.tsx'], 8 | java: ['.java'], 9 | c_sharp: ['.cs'], 10 | php: ['.php'], 11 | ruby: ['.rb'], 12 | python: ['.py'], 13 | rust: ['.rs'], 14 | go: ['.go'], 15 | } 16 | 17 | type ExtLangEntry = [string, LanguageName] 18 | 19 | const entries = Object.entries(glueExtByLanguageName).reduce((prev, entry) => { 20 | const newEntries: ExtLangEntry[] = entry[1].map((ext) => [ext, entry[0] as LanguageName]) 21 | return prev.concat(newEntries) 22 | }, []) 23 | 24 | const glueLanguageNameByExt = Object.fromEntries(entries) 25 | 26 | const glueExtensions = new Set(Object.keys(glueLanguageNameByExt)) 27 | 28 | export async function loadGlueSources( 29 | files: Files, 30 | globs: readonly string[] 31 | ): Promise[]> { 32 | return loadSources(files, globs, glueExtensions, glueLanguageNameByExt) 33 | } 34 | 35 | export function getLanguage(ext: string): LanguageName | undefined { 36 | return glueLanguageNameByExt[ext] 37 | } 38 | 39 | export async function loadGherkinSources( 40 | files: Files, 41 | globs: readonly string[] 42 | ): Promise[]> { 43 | return loadSources(files, globs, new Set(['.feature']), { '.feature': 'gherkin' }) 44 | } 45 | 46 | type LanguageNameByExt = Record 47 | 48 | export async function findUris(files: Files, globs: readonly string[]): Promise { 49 | // Run all the globs in parallel 50 | const urisPromises = globs.reduce[]>((prev, glob) => { 51 | const urisPromise = files.findUris(glob) 52 | return prev.concat(urisPromise) 53 | }, []) 54 | const uriArrays = await Promise.all(urisPromises) 55 | // Flatten them all 56 | const uris = uriArrays.flatMap((paths) => paths) 57 | return [...new Set(uris).values()].sort() 58 | } 59 | 60 | async function loadSources( 61 | files: Files, 62 | globs: readonly string[], 63 | extensions: Set, 64 | languageNameByExt: LanguageNameByExt 65 | ): Promise[]> { 66 | const uris = await findUris(files, globs) 67 | 68 | return Promise.all( 69 | uris 70 | .filter((uri) => extensions.has(extname(uri))) 71 | .map>>( 72 | (uri) => 73 | new Promise>((resolve) => { 74 | const languageName = languageNameByExt[extname(uri)] 75 | return files.readFile(uri).then((content) => 76 | resolve({ 77 | languageName, 78 | content, 79 | uri, 80 | }) 81 | ) 82 | }) 83 | ) 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Cucumber logo 3 |
4 | Cucumber Language Server 5 |

6 |

7 | A Language Server for Cucumber 8 |

9 | 10 |

11 | 12 | npm 13 | 14 | 15 | test-javascript-package 16 | 17 | 18 | release-package-github 19 | 20 | 21 | backers 22 | 23 | 24 | sponsors 25 | 26 |

27 | 28 | Provides most of the functionality offered by the 29 | [Cucumber Visual Studio Code Extension](https://github.com/cucumber/vscode) and can also be utilised with other editors that support the Language Server Protocol (LSP). 30 | 31 | ## Features 32 | 33 | See [Cucumber Language Service](https://github.com/cucumber/language-service), which implements most of the logic in this server. 34 | If you are looking to add a new feature, you should probably add it to [Cucumber Language Service](https://github.com/cucumber/language-service). 35 | 36 | ## Install 37 | 38 | Cucumber Language Server is [available on npm](https://www.npmjs.com/package/@cucumber/language-server): 39 | 40 | ```console 41 | npm install @cucumber/language-server 42 | ``` 43 | 44 | ### Settings 45 | 46 | The LSP client can provide settings to the server, but the server provides [reasonable defaults](https://github.com/cucumber/language-server/blob/main/src/CucumberLanguageServer.ts) (see `defaultSettings`) if the client does not 47 | provide them. 48 | 49 | The server retrieves `cucumber.*` settings from the client with a [workspace/configuration](https://microsoft.github.io/language-server-protocol/specification#workspace_configuration) request. 50 | 51 | See [Settings](https://github.com/cucumber/language-server/blob/main/src/types.ts) for details about the expected format. 52 | 53 | ## External VSCode Usage 54 | 55 | We've encountered an issue with the Node version used by [Treesitter](https://github.com/tree-sitter/tree-sitter/issues/2338), a 56 | dependency of this language server, when working outside of VSCode. For optimal 57 | compatibility, please use the same Node version as version 18 of VSCode. 58 | 59 | ## Support 60 | 61 | Support is [available from the community](https://cucumber.io/tools/cucumber-open/support/) if you need it. 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cucumber/language-server", 3 | "version": "1.7.0", 4 | "description": "Cucumber Language Server", 5 | "engines": { 6 | "node": ">=16.0.0" 7 | }, 8 | "type": "module", 9 | "main": "dist/cjs/src/index.js", 10 | "module": "dist/esm/src/index.js", 11 | "types": "dist/esm/src/index.d.ts", 12 | "exports": { 13 | ".": { 14 | "import": "./dist/esm/src/index.js", 15 | "require": "./dist/cjs/src/index.js" 16 | }, 17 | "./node": { 18 | "import": "./dist/esm/src/node/index.js", 19 | "require": "./dist/cjs/src/node/index.js" 20 | }, 21 | "./wasm": { 22 | "import": "./dist/esm/src/wasm/index.js", 23 | "require": "./dist/cjs/src/wasm/index.js" 24 | } 25 | }, 26 | "files": [ 27 | "dist/cjs/src", 28 | "dist/cjs/package.json", 29 | "dist/esm/src", 30 | "node", 31 | "wasm" 32 | ], 33 | "bin": { 34 | "cucumber-language-server": "bin/cucumber-language-server.cjs" 35 | }, 36 | "scripts": { 37 | "build:cjs": "tsc --build tsconfig.build-cjs.json && cp package.cjs.json dist/cjs/package.json", 38 | "build:esm": "tsc --build tsconfig.build-esm.json", 39 | "build:version": "node --eval \"console.log('export const version = \\'' + require('./package.json').version + '\\'')\" > src/version.ts", 40 | "build": "npm run build:version && npm run build:cjs && npm run build:esm", 41 | "test": "npm run test:cjs", 42 | "test:cjs": "npm run build:cjs && mocha --no-config --recursive dist/cjs/test", 43 | "pretest": "npm run build:version", 44 | "prepublishOnly": "npm run build", 45 | "eslint-fix": "eslint --ext ts --max-warnings 0 --fix src test", 46 | "eslint": "eslint --ext ts --max-warnings 0 src test", 47 | "prepare": "husky" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "git://github.com/cucumber/language-server.git" 52 | }, 53 | "keywords": [ 54 | "cucumber", 55 | "gherkin", 56 | "lsp" 57 | ], 58 | "author": "Cucumber Limited ", 59 | "contributors": [ 60 | "Aslak Hellesøy ", 61 | "Aurélien Reeves ", 62 | "Binh Duc Tran ", 63 | "Kieran Ryan ", 64 | "William Boman " 65 | ], 66 | "license": "MIT", 67 | "bugs": { 68 | "url": "https://github.com/cucumber/language-server/issues" 69 | }, 70 | "homepage": "https://github.com/cucumber/language-server#readme", 71 | "lint-staged": { 72 | "{src,test}/**/*.ts": "npm run eslint-fix" 73 | }, 74 | "devDependencies": { 75 | "@cucumber/cucumber": "12.4.0", 76 | "@types/mocha": "10.0.10", 77 | "@types/node": "24.10.4", 78 | "@typescript-eslint/eslint-plugin": "7.18.0", 79 | "@typescript-eslint/parser": "7.18.0", 80 | "eslint": "8.57.1", 81 | "eslint-config-prettier": "10.1.8", 82 | "eslint-plugin-import": "2.32.0", 83 | "eslint-plugin-n": "17.23.1", 84 | "eslint-plugin-prettier": "5.5.4", 85 | "eslint-plugin-simple-import-sort": "12.1.1", 86 | "husky": "9.1.7", 87 | "lint-staged": "^16.0.0", 88 | "mocha": "11.7.5", 89 | "prettier": "3.7.4", 90 | "ts-node": "10.9.2", 91 | "typescript": "5.9.3", 92 | "vscode-jsonrpc": "8.2.1", 93 | "vscode-languageserver-protocol": "3.17.2" 94 | }, 95 | "dependencies": { 96 | "@cucumber/gherkin-utils": "^10.0.0", 97 | "@cucumber/language-service": "^1.7.0", 98 | "fast-glob": "3.3.3", 99 | "source-map-support": "0.5.21", 100 | "vscode-languageserver": "8.0.2", 101 | "vscode-languageserver-textdocument": "1.0.12", 102 | "vscode-languageserver-types": "3.17.5" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /test/standalone.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { ChildProcess, fork } from 'child_process' 3 | import { Duplex } from 'stream' 4 | import { NullLogger, StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node' 5 | import { 6 | createProtocolConnection, 7 | DidChangeConfigurationNotification, 8 | DidChangeConfigurationParams, 9 | InitializeParams, 10 | InitializeRequest, 11 | LogMessageNotification, 12 | LogMessageParams, 13 | ProtocolConnection, 14 | } from 'vscode-languageserver' 15 | 16 | import { Settings } from '../src/types.js' 17 | 18 | describe('Standalone', () => { 19 | let serverFork: ChildProcess 20 | let logMessages: LogMessageParams[] 21 | let clientConnection: ProtocolConnection 22 | 23 | beforeEach(async () => { 24 | logMessages = [] 25 | serverFork = fork('./bin/cucumber-language-server.cjs', ['--stdio'], { 26 | silent: true, 27 | }) 28 | 29 | const initializeParams: InitializeParams = { 30 | rootUri: `file://${process.cwd()}`, 31 | processId: NaN, // This id is used by vscode-languageserver. Set as NaN so that the watchdog responsible for watching this process does not run. 32 | capabilities: { 33 | workspace: { 34 | configuration: true, 35 | didChangeWatchedFiles: { 36 | dynamicRegistration: true, 37 | }, 38 | }, 39 | textDocument: { 40 | moniker: { 41 | dynamicRegistration: false, 42 | }, 43 | completion: { 44 | completionItem: { 45 | snippetSupport: true, 46 | }, 47 | }, 48 | semanticTokens: { 49 | tokenTypes: [], 50 | tokenModifiers: [], 51 | formats: [], 52 | requests: {}, 53 | }, 54 | formatting: { 55 | dynamicRegistration: true, 56 | }, 57 | }, 58 | }, 59 | workspaceFolders: null, 60 | } 61 | if (!serverFork.stdin || !serverFork.stdout) { 62 | throw 'Process created without stdio streams' 63 | } 64 | clientConnection = createProtocolConnection( 65 | new StreamMessageReader(serverFork.stdout as Duplex), 66 | new StreamMessageWriter(serverFork.stdin as Duplex), 67 | NullLogger 68 | ) 69 | clientConnection.onError((err) => { 70 | console.error('ERROR', err) 71 | }) 72 | clientConnection.onNotification(LogMessageNotification.type, (params) => { 73 | if (params.type !== 3) { 74 | logMessages.push(params) 75 | } 76 | }) 77 | clientConnection.onUnhandledNotification((n) => { 78 | console.error('Unhandled notification', n) 79 | }) 80 | clientConnection.listen() 81 | const { serverInfo } = await clientConnection.sendRequest( 82 | InitializeRequest.type, 83 | initializeParams 84 | ) 85 | assert.strictEqual(serverInfo?.name, 'Cucumber Language Server') 86 | }) 87 | 88 | afterEach(() => { 89 | clientConnection.end() 90 | clientConnection.dispose() 91 | serverFork.kill('SIGTERM') // Try to terminate first 92 | serverFork.kill('SIGKILL') // Then try to kill if it is not killed 93 | }) 94 | 95 | context('workspace/didChangeConfiguration', () => { 96 | it(`startup success`, async () => { 97 | // First we need to configure the server, telling it where to find Gherkin documents and Glue code. 98 | // Note that *pushing* settings from the client to the server is deprecated in the LSP. We're only using it 99 | // here because it's easier to implement in the test. 100 | const settings: Settings = { 101 | features: ['testdata/**/*.feature'], 102 | glue: ['testdata/**/*.js'], 103 | parameterTypes: [], 104 | snippetTemplates: {}, 105 | } 106 | const configParams: DidChangeConfigurationParams = { 107 | settings, 108 | } 109 | 110 | await clientConnection.sendNotification(DidChangeConfigurationNotification.type, configParams) 111 | 112 | await new Promise((resolve) => setTimeout(resolve, 1000)) 113 | 114 | assert.strictEqual( 115 | logMessages.length, 116 | // TODO: change this to 0 when `workspace/semanticTokens/refresh` issue was solved 117 | 1, 118 | // print readable log messages 119 | logMessages 120 | .map(({ type, message }) => `**Type**: ${type}\n**Message**:\n${message}\n`) 121 | .join('\n--------------------\n') 122 | ) 123 | }) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /test/CucumberLanguageServer.test.ts: -------------------------------------------------------------------------------- 1 | import { WasmParserAdapter } from '@cucumber/language-service/wasm' 2 | import assert from 'assert' 3 | import { Duplex } from 'stream' 4 | import { NullLogger, StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node' 5 | import { 6 | Connection, 7 | createProtocolConnection, 8 | DidChangeConfigurationNotification, 9 | DidChangeConfigurationParams, 10 | InitializeParams, 11 | InitializeRequest, 12 | InsertTextFormat, 13 | LogMessageNotification, 14 | ProtocolConnection, 15 | TextDocuments, 16 | } from 'vscode-languageserver' 17 | import { createConnection } from 'vscode-languageserver/node' 18 | import { 19 | CompletionParams, 20 | CompletionRequest, 21 | } from 'vscode-languageserver-protocol/lib/common/protocol' 22 | import { TextDocument } from 'vscode-languageserver-textdocument' 23 | import { CompletionItem, CompletionItemKind } from 'vscode-languageserver-types' 24 | 25 | import { CucumberLanguageServer } from '../src/CucumberLanguageServer.js' 26 | import { NodeFiles } from '../src/node/NodeFiles.js' 27 | import { Settings } from '../src/types.js' 28 | 29 | describe('CucumberLanguageServer', () => { 30 | let inputStream: Duplex 31 | let outputStream: Duplex 32 | let clientConnection: ProtocolConnection 33 | let serverConnection: Connection 34 | let documents: TextDocuments 35 | 36 | beforeEach(async () => { 37 | inputStream = new TestStream() 38 | outputStream = new TestStream() 39 | serverConnection = createConnection(inputStream, outputStream) 40 | documents = new TextDocuments(TextDocument) 41 | 42 | new CucumberLanguageServer( 43 | serverConnection, 44 | documents, 45 | new WasmParserAdapter('node_modules/@cucumber/language-service/dist'), 46 | (rootUri) => new NodeFiles(rootUri), 47 | () => undefined 48 | ) 49 | serverConnection.listen() 50 | 51 | const initializeParams: InitializeParams = { 52 | rootUri: `file://${process.cwd()}`, 53 | processId: NaN, // This id is used by vscode-languageserver. Set as NaN so that the watchdog responsible for watching this process does not run. 54 | capabilities: { 55 | workspace: { 56 | configuration: true, 57 | didChangeWatchedFiles: { 58 | dynamicRegistration: true, 59 | }, 60 | }, 61 | textDocument: { 62 | moniker: { 63 | dynamicRegistration: false, 64 | }, 65 | completion: { 66 | completionItem: { 67 | snippetSupport: true, 68 | }, 69 | }, 70 | semanticTokens: { 71 | tokenTypes: [], 72 | tokenModifiers: [], 73 | formats: [], 74 | requests: {}, 75 | }, 76 | formatting: { 77 | dynamicRegistration: true, 78 | }, 79 | }, 80 | }, 81 | workspaceFolders: null, 82 | } 83 | clientConnection = createProtocolConnection( 84 | new StreamMessageReader(outputStream), 85 | new StreamMessageWriter(inputStream), 86 | NullLogger 87 | ) 88 | clientConnection.onError((err) => { 89 | console.error('ERROR', err) 90 | }) 91 | // Ignore log messages 92 | clientConnection.onNotification(LogMessageNotification.type, () => undefined) 93 | clientConnection.onUnhandledNotification((n) => { 94 | console.error('Unhandled notification', n) 95 | }) 96 | clientConnection.listen() 97 | const { serverInfo } = await clientConnection.sendRequest( 98 | InitializeRequest.type, 99 | initializeParams 100 | ) 101 | assert.strictEqual(serverInfo?.name, 'Cucumber Language Server') 102 | }) 103 | 104 | afterEach(() => { 105 | clientConnection.end() 106 | clientConnection.dispose() 107 | serverConnection.dispose() 108 | }) 109 | 110 | context('textDocument/completion', () => { 111 | const fileExtensions = [ 112 | // Javascript 113 | 'js', 114 | 'cjs', 115 | 'mjs', 116 | 117 | // Typescript 118 | 'ts', 119 | 'cts', 120 | 'mts', 121 | ] 122 | 123 | fileExtensions.forEach((fileExtension) => 124 | it(`returns completion items for *.${fileExtension} files`, async () => { 125 | // First we need to configure the server, telling it where to find Gherkin documents and Glue code. 126 | // Note that *pushing* settings from the client to the server is deprecated in the LSP. We're only using it 127 | // here because it's easier to implement in the test. 128 | const settings: Settings = { 129 | features: ['testdata/**/*.feature'], 130 | glue: [`testdata/**/*.${fileExtension}`], 131 | parameterTypes: [], 132 | snippetTemplates: {}, 133 | } 134 | const configParams: DidChangeConfigurationParams = { 135 | settings, 136 | } 137 | await clientConnection.sendNotification( 138 | DidChangeConfigurationNotification.type, 139 | configParams 140 | ) 141 | 142 | // TODO: Wait for a WorkDoneProgressEnd notification instead 143 | await new Promise((resolve) => setTimeout(resolve, 1000)) 144 | 145 | // Create a document for auto completion 146 | documents.get = () => 147 | TextDocument.create( 148 | 'testdoc', 149 | 'gherkin', 150 | 1, 151 | `Feature: Hello 152 | Scenario: World 153 | Given I have 154 | ` 155 | ) 156 | const completionParams: CompletionParams = { 157 | textDocument: { 158 | uri: 'features/test.feature', 159 | }, 160 | position: { 161 | line: 2, // The step line 162 | character: 16, // End of the step line 163 | }, 164 | } 165 | const completionItems = await clientConnection.sendRequest( 166 | CompletionRequest.type, 167 | completionParams 168 | ) 169 | const expected: CompletionItem[] = [ 170 | { 171 | label: 'I have {int} cukes', 172 | filterText: 'I have', 173 | sortText: '1000', 174 | insertTextFormat: InsertTextFormat.Snippet, 175 | kind: CompletionItemKind.Text, 176 | labelDetails: {}, 177 | textEdit: { 178 | newText: 'I have ${1|5,8|} cukes', 179 | range: { 180 | start: { 181 | line: 2, 182 | character: 10, 183 | }, 184 | end: { 185 | line: 2, 186 | character: 16, 187 | }, 188 | }, 189 | }, 190 | }, 191 | ] 192 | assert.deepStrictEqual(completionItems, expected) 193 | }) 194 | ) 195 | }) 196 | }) 197 | 198 | class TestStream extends Duplex { 199 | _write(chunk: string, _encoding: string, done: () => void) { 200 | this.emit('data', chunk) 201 | done() 202 | } 203 | 204 | _read() { 205 | // no-op 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | ## [Unreleased] 9 | ### Fixed 10 | - User's `snippetTemplates` config is ignored 11 | 12 | ## [1.7.0] - 2025-05-18 13 | ### Changed 14 | - Update dependency @cucumber/language-service to 1.7.0 15 | 16 | ## [1.6.0] - 2024-04-21 17 | ### Added 18 | - (Go) Support for Godog step definitions ([#106](https://github.com/cucumber/language-server/pull/106)) 19 | 20 | ## [1.5.0] - 2024-03-26 21 | ### Added 22 | - Allow Javascript/Typescript glue files with the following file extensions: cjs, mjs, cts, mts - [#85](https://github.com/cucumber/language-server/pull/85) 23 | 24 | ### Fixed 25 | - Fixed c-sharp glob paths for step definitions and feature files - [#89](https://github.com/cucumber/language-server/pull/89) 26 | - Specify minimum supported node version ([#100](https://github.com/cucumber/language-server/pull/100)) 27 | - Fixed the issue preventing standalone operation outside of VS Code - [#74](https://github.com/cucumber/language-server/pull/74) 28 | 29 | ## [1.4.0] - 2022-12-08 30 | ### Added 31 | - Added support for JavaScript - [#42](https://github.com/cucumber/language-service/issues/42), [#115](https://github.com/cucumber/language-service/pull/115), [#120](https://github.com/cucumber/language-service/pull/120) 32 | 33 | ### Fixed 34 | - Fixed a regression in the python language implementation for regexes [#119](https://github.com/cucumber/language-service/pull/119) 35 | 36 | ## [1.3.0] - 2022-11-28 37 | ### Added 38 | - Expose `registry` and `expressions` [#73](https://github.com/cucumber/language-server/pull/73). 39 | 40 | ## [1.2.0] - 2022-11-18 41 | ### Added 42 | - Added context to python snippet to properly support `behave` 43 | - Added `ParameterType` support to the python language implementation. This is currently supported via [cuke4behave](http://gitlab.com/cuke4behave/cuke4behave) 44 | 45 | ## [1.1.1] - 2022-10-11 46 | ### Fixed 47 | - (TypeScript) Fix bug in template literal recognition 48 | 49 | ## [1.1.0] - 2022-10-10 50 | ### Added 51 | - Add support for [document symbols](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) ([#98](https://github.com/cucumber/language-service/issues/98), [#106](https://github.com/cucumber/language-service/pull/106)) 52 | - (Java) Recognise regexps with `(?i)`, with the caveat that the resulting JavaScript `RegExp` is _not_ case insensitive ([#100](https://github.com/cucumber/language-service/issues/100), [#108](https://github.com/cucumber/language-service/pull/108)) 53 | - (TypeScript) Add support for template literals without subsitutions. ([#101](https://github.com/cucumber/language-service/issues/101), [#107](https://github.com/cucumber/language-service/pull/107)) 54 | 55 | ### Fixed 56 | - Fix standalone startup (for use in e.g. neovim) ([#66](https://github.com/cucumber/language-server/issues/66), [#68](https://github.com/cucumber/language-server/pull/68)) 57 | 58 | ## [1.0.1] - 2022-10-10 59 | ### Fixed 60 | - Fix rust snippet fn name to lowercase ([#103](https://github.com/cucumber/language-service/issues/103), [#104](https://github.com/cucumber/language-service/pull/104)) 61 | 62 | ## [1.0.0] - 2022-10-05 63 | ### Added 64 | - Support for [Cucumber Rust](https://github.com/cucumber-rs/cucumber) ([#82](https://github.com/cucumber/language-service/issues/82), [#99](https://github.com/cucumber/language-service/pull/99)) 65 | 66 | ### Fixed 67 | - Don't throw an error when Regular Expressions have optional capture groups ([#96](https://github.com/cucumber/language-service/issues/96), [#97](https://github.com/cucumber/language-service/pull/97)). 68 | 69 | ## [0.13.1] - 2022-09-13 70 | ### Fixed 71 | - Fixed a regression in URI handling on Windows. 72 | 73 | ## [0.13.0] - 2022-09-12 74 | ### Added 75 | - New API to make it possible to start the server in-process. This enables some additional features in the vscode extension. ([#65](https://github.com/cucumber/language-server/pull/65)) 76 | 77 | ## [0.12.14] - 2022-09-10 78 | ### Fixed 79 | - Support `.tsx` in step definitions 80 | - Bugfixes in [@cucumber/language-service 0.33.0](https://github.com/cucumber/language-service/releases/tag/v0.33.0) 81 | 82 | ## [0.12.13] - 2022-08-29 83 | ### Fixed 84 | - Update default globs to work with Pytest-BDD conventions ([#64](https://github.com/cucumber/language-server/pull/64) 85 | 86 | ## [0.12.12] - 2022-08-27 87 | ### Fixed 88 | - Add default values for Python [#63](https://github.com/cucumber/language-server/pull/63) 89 | - Bugfixes in [@cucumber/language-service 0.32.0](https://github.com/cucumber/language-service/releases/tag/v0.32.0) 90 | 91 | ## [0.12.11] - 2022-07-14 92 | ### Fixed 93 | - Use wasm server in standalone mode. Fixes [#56](https://github.com/cucumber/language-server/issues/56) 94 | - Fix invalid file URI on Windows. [#57](https://github.com/cucumber/language-server/pull/57). Fixes 95 | [cucumber/vscode#78]https://github.com/cucumber/vscode/issues/78), 96 | [cucumber/vscode#82](https://github.com/cucumber/vscode/issues/82), 97 | [cucumber/language-service#70](https://github.com/cucumber/language-service/issues/70) 98 | - Bugfixes in [@cucumber/language-service 0.31.0](https://github.com/cucumber/language-service/releases/tag/v0.31.0) 99 | 100 | ## [0.12.10] - 2022-06-14 101 | ### Fixed 102 | - Bugfixes in [@cucumber/language-service 0.30.0](https://github.com/cucumber/language-service/releases/tag/v0.30.0) 103 | 104 | ## [0.12.9] - 2022-05-26 105 | ### Fixed 106 | - Log working directory in addition to root path 107 | 108 | ## [0.12.8] - 2022-05-26 109 | ### Fixed 110 | - Don't throw an error when generating suggestions for RegExp. 111 | 112 | ## [0.12.7] - 2022-05-26 113 | ### Fixed 114 | - Improved logging 115 | 116 | ## [0.12.6] - 2022-05-26 117 | ### Fixed 118 | - Don't crash on optionals following non-text or whitespace 119 | 120 | ## [0.12.5] - 2022-05-25 121 | ### Fixed 122 | - Upgrade to `@cucumber/language-service 0.25.0` 123 | 124 | ## [0.12.4] - 2022-05-25 125 | ### Fixed 126 | - Generate step definition now correctly uses `Given`, `When` or `Then` for undefined steps that use `And` or `But` 127 | - Generated C# step definitions now follow SpecFlow conventions. 128 | 129 | ## [0.12.3] - 2022-05-25 130 | ### Fixed 131 | - Correctly parse Java parameter types 132 | 133 | ## [0.12.2] - 2022-05-24 134 | ### Fixed 135 | - No longer throw `Failed to reindex: No parameter type named ***` for custom parameter types. 136 | - Fixed other concurrency bugs. 137 | - Let the user choose in what file to generate step definitions. 138 | 139 | ## [0.12.1] - 2022-05-24 140 | ### Fixed 141 | - Fixed a bug with snippet generation 142 | 143 | ## [0.12.0] - 2022-05-24 144 | ### Added 145 | - Add Generate Step Definition (`textDocument/codeAction`) ([#45](https://github.com/cucumber/language-server/pull/45)) 146 | 147 | ## [0.11.0] - 2022-05-23 148 | ### Added 149 | - Add Go To Step Definition (`textDocument/definition`) ([#46](https://github.com/cucumber/language-server/pull/46)) 150 | 151 | ## [0.10.4] - 2022-05-12 152 | ### Fixed 153 | - Don't error when a parameter type is already registered 154 | 155 | ## [0.10.3] - 2022-05-12 156 | ### Fixed 157 | - Tell client to refresh semantic tokens after a reindex 158 | 159 | ## [0.10.2] - 2022-05-12 160 | ### Fixed 161 | - Automatically update all Gherkin documents when glue changes 162 | 163 | ## [0.10.1] - 2022-05-12 164 | ### Fixed 165 | - Don't error if a step def expression fails to parse. 166 | 167 | ## [0.10.0] - 2022-05-12 168 | ### Fixed 169 | - Parse files correctly if the user has spefied globs without extensions. 170 | 171 | ## [0.9.0] - 2022-05-11 172 | ### Fixed 173 | - Ignore parse errors and print them to STDERR 174 | 175 | ## [0.8.2] - 2022-05-10 176 | ### Fixed 177 | - The `0.8.1` release failed 178 | 179 | ## [0.8.1] - 2022-05-10 180 | ### Fixed 181 | - Autocomplete suggestions are showing better results 182 | 183 | ## [0.8.0] - 2022-05-09 184 | ### Added 185 | - Support for Ruby 186 | 187 | ## [0.7.1] - 2022-05-05 188 | ### Fixed 189 | - Autocomplete for unmatched step definitions 190 | 191 | ## [0.7.0] - 2022-04-27 192 | ### Added 193 | - Support for C# 194 | - Support for PHP 195 | 196 | ### Changed 197 | - Remove external dependency on `@cucumber/language-service` - always use tree-sitter wasm 198 | 199 | ## [0.6.0] - 2022-04-26 200 | ### Changed 201 | - Use tree-sitter Node.js bindings instead of web (WASM) bindings. 202 | - Updated to `@cucumber/language-service 0.13.0` 203 | 204 | ### Removed 205 | - Support for Node.js 17 removed - see [tree-sitter/tree-sitter#1503](https://github.com/tree-sitter/tree-sitter/issues/1503) 206 | 207 | ## [0.5.1] - 2022-02-11 208 | ### Fixed 209 | - Fix server immediately crashing when starting [#30](https://github.com/cucumber/language-server/pull/30) 210 | 211 | ## [0.5.0] - 2022-02-05 212 | ### Changed 213 | - Moved tree-sitter logic to language-service [#28](https://github.com/cucumber/language-server/issues/28) 214 | 215 | ### Fixed 216 | - Do not crash anymore when cucumber settings are missing 217 | 218 | ## [0.4.0] - 2022-01-19 219 | ### Added 220 | - Added settings (with reasonable defaults) so the client can specify where the source 221 | code is. The server uses this for auto complete and detecting defined steps. 222 | 223 | ### Fixed 224 | - Fixed auto complete by re-enabling the building of the index that backs it. 225 | 226 | ## [0.3.3] - 2022-01-10 227 | ### Fixed 228 | - Export `./bin/cucumber-language-server.cjs` 229 | - Fix server initialization 230 | 231 | ## [0.3.2] - 2022-01-10 232 | ### Fixed 233 | - Fix startup script 234 | 235 | ## [0.3.1] - 2021-11-08 236 | ### Fixed 237 | - Fix release process 238 | 239 | ## [0.3.0] - 2021-11-08 240 | ### Added 241 | - Add tree-sitter functionality for extracting expressions from source code 242 | 243 | ## [0.2.0] - 2021-10-12 244 | ### Changed 245 | - Upgrade to `@cucumber/cucumber-expressions 14.0.0` 246 | 247 | ## [0.1.0] - 2021-09-07 248 | ### Added 249 | - Document Formatting 250 | ([#1732](https://github.com/cucumber/common/pull/1732) 251 | [aslakhellesoy](https://github.com/aslakhellesoy)) 252 | 253 | ## [0.0.1] - 2021-09-02 254 | ### Added 255 | - First release 256 | 257 | [Unreleased]: https://github.com/cucumber/language-server/compare/v1.7.0...HEAD 258 | [1.7.0]: https://github.com/cucumber/language-server/compare/v1.6.0...v1.7.0 259 | [1.6.0]: https://github.com/cucumber/language-server/compare/v1.5.0...v1.6.0 260 | [1.5.0]: https://github.com/cucumber/language-server/compare/v1.4.0...v1.5.0 261 | [1.4.0]: https://github.com/cucumber/language-server/compare/v1.3.0...v1.4.0 262 | [1.3.0]: https://github.com/cucumber/language-server/compare/v1.2.0...v1.3.0 263 | [1.2.0]: https://github.com/cucumber/language-server/compare/v1.1.1...v1.2.0 264 | [1.1.1]: https://github.com/cucumber/language-server/compare/v1.1.0...v1.1.1 265 | [1.1.0]: https://github.com/cucumber/language-server/compare/v1.0.1...v1.1.0 266 | [1.0.1]: https://github.com/cucumber/language-server/compare/v1.0.0...v1.0.1 267 | [1.0.0]: https://github.com/cucumber/language-server/compare/v0.13.1...v1.0.0 268 | [0.13.1]: https://github.com/cucumber/language-server/compare/v0.13.0...v0.13.1 269 | [0.13.0]: https://github.com/cucumber/language-server/compare/v0.12.14...v0.13.0 270 | [0.12.14]: https://github.com/cucumber/language-server/compare/v0.12.13...v0.12.14 271 | [0.12.13]: https://github.com/cucumber/language-server/compare/v0.12.12...v0.12.13 272 | [0.12.12]: https://github.com/cucumber/language-server/compare/v0.12.11...v0.12.12 273 | [0.12.11]: https://github.com/cucumber/language-server/compare/v0.12.10...v0.12.11 274 | [0.12.10]: https://github.com/cucumber/language-server/compare/v0.12.9...v0.12.10 275 | [0.12.9]: https://github.com/cucumber/language-server/compare/v0.12.8...v0.12.9 276 | [0.12.8]: https://github.com/cucumber/language-server/compare/v0.12.7...v0.12.8 277 | [0.12.7]: https://github.com/cucumber/language-server/compare/v0.12.6...v0.12.7 278 | [0.12.6]: https://github.com/cucumber/language-server/compare/v0.12.5...v0.12.6 279 | [0.12.5]: https://github.com/cucumber/language-server/compare/v0.12.4...v0.12.5 280 | [0.12.4]: https://github.com/cucumber/language-server/compare/v0.12.3...v0.12.4 281 | [0.12.3]: https://github.com/cucumber/language-server/compare/v0.12.2...v0.12.3 282 | [0.12.2]: https://github.com/cucumber/language-server/compare/v0.12.1...v0.12.2 283 | [0.12.1]: https://github.com/cucumber/language-server/compare/v0.12.0...v0.12.1 284 | [0.12.0]: https://github.com/cucumber/language-server/compare/v0.11.0...v0.12.0 285 | [0.11.0]: https://github.com/cucumber/language-server/compare/v0.10.4...v0.11.0 286 | [0.10.4]: https://github.com/cucumber/language-server/compare/v0.10.3...v0.10.4 287 | [0.10.3]: https://github.com/cucumber/language-server/compare/v0.10.2...v0.10.3 288 | [0.10.2]: https://github.com/cucumber/language-server/compare/v0.10.1...v0.10.2 289 | [0.10.1]: https://github.com/cucumber/language-server/compare/v0.10.0...v0.10.1 290 | [0.10.0]: https://github.com/cucumber/language-server/compare/v0.9.0...v0.10.0 291 | [0.9.0]: https://github.com/cucumber/language-server/compare/v0.8.2...v0.9.0 292 | [0.8.2]: https://github.com/cucumber/language-server/compare/v0.8.1...v0.8.2 293 | [0.8.1]: https://github.com/cucumber/language-server/compare/v0.8.0...v0.8.1 294 | [0.8.0]: https://github.com/cucumber/language-server/compare/v0.7.1...v0.8.0 295 | [0.7.1]: https://github.com/cucumber/language-server/compare/v0.7.0...v0.7.1 296 | [0.7.0]: https://github.com/cucumber/language-server/compare/v0.6.0...v0.7.0 297 | [0.6.0]: https://github.com/cucumber/language-server/compare/v0.5.1...v0.6.0 298 | [0.5.1]: https://github.com/cucumber/language-server/compare/v0.5.0...v0.5.1 299 | [0.5.0]: https://github.com/cucumber/language-server/compare/v0.4.0...v0.5.0 300 | [0.4.0]: https://github.com/cucumber/language-server/compare/v0.3.3...v0.4.0 301 | [0.3.3]: https://github.com/cucumber/language-server/compare/v0.3.2...v0.3.3 302 | [0.3.2]: https://github.com/cucumber/language-server/compare/v0.3.1...v0.3.2 303 | [0.3.1]: https://github.com/cucumber/language-server/compare/v0.3.0...v0.3.1 304 | [0.3.0]: https://github.com/cucumber/language-server/compare/v0.2.0...v0.3.0 305 | [0.2.0]: https://github.com/cucumber/language-server/compare/v0.1.0...v0.2.0 306 | [0.1.0]: https://github.com/cucumber/language-server/compare/v0.0.1...v0.1.0 307 | [0.0.1]: https://github.com/cucumber/common/tree/v0.0.1 308 | -------------------------------------------------------------------------------- /src/CucumberLanguageServer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildSuggestions, 3 | CucumberExpressions, 4 | ExpressionBuilder, 5 | ExpressionBuilderResult, 6 | getGenerateSnippetCodeAction, 7 | getGherkinCompletionItems, 8 | getGherkinDiagnostics, 9 | getGherkinDocumentFeatureSymbol, 10 | getGherkinFormattingEdits, 11 | getGherkinSemanticTokens, 12 | getStepDefinitionLocationLinks, 13 | Index, 14 | jsSearchIndex, 15 | ParserAdapter, 16 | semanticTokenTypes, 17 | Suggestion, 18 | } from '@cucumber/language-service' 19 | import { 20 | CodeAction, 21 | CodeActionKind, 22 | ConfigurationRequest, 23 | Connection, 24 | DidChangeConfigurationNotification, 25 | ServerCapabilities, 26 | TextDocuments, 27 | TextDocumentSyncKind, 28 | } from 'vscode-languageserver' 29 | import { TextDocument } from 'vscode-languageserver-textdocument' 30 | 31 | import { buildStepTexts } from './buildStepTexts.js' 32 | import { extname, Files } from './Files.js' 33 | import { getLanguage, loadGherkinSources, loadGlueSources } from './fs.js' 34 | import { getStepDefinitionSnippetLinks } from './getStepDefinitionSnippetLinks.js' 35 | import { Settings } from './types.js' 36 | import { version } from './version.js' 37 | 38 | type ServerInfo = { 39 | name: string 40 | version: string 41 | } 42 | 43 | // In order to allow 0-config in LSP clients we provide default settings. 44 | // This should be consistent with the `README.md` in `cucumber/vscode` - this is to 45 | // ensure the docs for the plugin reflect the defaults. 46 | const defaultSettings: Settings = { 47 | // IMPORTANT: If you change features or glue below, please also create a PR to update 48 | // the vscode extension defaults accordingly in https://github.com/cucumber/vscode/blob/main/README.md#extension-settings 49 | features: [ 50 | // Cucumber-JVM 51 | 'src/test/**/*.feature', 52 | // Cucumber-Ruby, Cucumber-Js, Behat, Behave, Godog 53 | 'features/**/*.feature', 54 | // Pytest-BDD 55 | 'tests/**/*.feature', 56 | // SpecFlow 57 | '*specs*/**/*.feature', 58 | ], 59 | glue: [ 60 | // Cucumber-JVM 61 | 'src/test/**/*.java', 62 | // Cucumber-Js 63 | 'features/**/*.ts', 64 | 'features/**/*.tsx', 65 | 'features/**/*.js', 66 | 'features/**/*.jsx', 67 | // Behat 68 | 'features/**/*.php', 69 | // Behave 70 | 'features/**/*.py', 71 | // Pytest-BDD 72 | 'tests/**/*.py', 73 | // Cucumber Rust 74 | 'tests/**/*.rs', 75 | 'features/**/*.rs', 76 | // Cucumber-Ruby 77 | 'features/**/*.rb', 78 | // SpecFlow 79 | '*specs*/**/*.cs', 80 | // Godog 81 | 'features/**/*_test.go', 82 | ], 83 | parameterTypes: [], 84 | snippetTemplates: {}, 85 | } 86 | 87 | export class CucumberLanguageServer { 88 | private readonly expressionBuilder: ExpressionBuilder 89 | private searchIndex: Index 90 | private expressionBuilderResult: ExpressionBuilderResult | undefined = undefined 91 | private reindexingTimeout: NodeJS.Timeout 92 | private rootUri: string 93 | private files: Files 94 | public registry: CucumberExpressions.ParameterTypeRegistry 95 | public expressions: readonly CucumberExpressions.Expression[] = [] 96 | public suggestions: readonly Suggestion[] = [] 97 | 98 | constructor( 99 | private readonly connection: Connection, 100 | private readonly documents: TextDocuments, 101 | parserAdapter: ParserAdapter, 102 | private readonly makeFiles: (rootUri: string) => Files, 103 | private readonly onReindexed: ( 104 | registry: CucumberExpressions.ParameterTypeRegistry, 105 | expressions: readonly CucumberExpressions.Expression[], 106 | suggestions: readonly Suggestion[] 107 | ) => void 108 | ) { 109 | this.expressionBuilder = new ExpressionBuilder(parserAdapter) 110 | 111 | connection.onInitialize(async (params) => { 112 | // connection.console.log(`PARAMS: ${JSON.stringify(params, null, 2)}`) 113 | await parserAdapter.init() 114 | if (params.clientInfo) { 115 | connection.console.info( 116 | `Initializing connection from ${params.clientInfo.name} ${params.clientInfo.version}` 117 | ) 118 | } else { 119 | connection.console.info(`Initializing connection from unknown client`) 120 | } 121 | 122 | if (params.rootPath) { 123 | this.rootUri = `file://${params.rootPath}` 124 | } else if (params.rootUri) { 125 | this.rootUri = params.rootUri 126 | } else if (params.workspaceFolders && params.workspaceFolders.length > 0) { 127 | this.rootUri = params.workspaceFolders[0].uri 128 | } else { 129 | connection.console.error(`Could not determine rootPath`) 130 | } 131 | this.files = makeFiles(this.rootUri) 132 | // Some users have reported that the globs don't find any files. This is to debug that issue 133 | connection.console.info(`Root uri : ${this.rootUri}`) 134 | connection.console.info(`Current dir : ${process.cwd()}`) 135 | 136 | if (params.capabilities.workspace?.configuration) { 137 | connection.onDidChangeConfiguration((params) => { 138 | this.connection.console.info(`Client sent workspace/configuration`) 139 | this.reindex(params.settings).catch((err) => { 140 | connection.console.error(`Failed to reindex: ${err.message}`) 141 | }) 142 | }) 143 | try { 144 | await connection.client.register(DidChangeConfigurationNotification.type) 145 | } catch (err) { 146 | connection.console.info(`Client does not support client/registerCapability. This is OK.`) 147 | } 148 | } else { 149 | this.connection.console.info('onDidChangeConfiguration is disabled') 150 | } 151 | 152 | if (params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration) { 153 | connection.onDidChangeWatchedFiles(async () => { 154 | connection.console.info(`onDidChangeWatchedFiles`) 155 | }) 156 | } else { 157 | connection.console.info('onDidChangeWatchedFiles is disabled') 158 | } 159 | 160 | if (params.capabilities.textDocument?.semanticTokens) { 161 | connection.languages.semanticTokens.onDelta(() => { 162 | return { 163 | data: [], 164 | } 165 | }) 166 | connection.languages.semanticTokens.onRange(() => { 167 | return { 168 | data: [], 169 | } 170 | }) 171 | connection.languages.semanticTokens.on((semanticTokenParams) => { 172 | const doc = documents.get(semanticTokenParams.textDocument.uri) 173 | if (!doc) return { data: [] } 174 | const gherkinSource = doc.getText() 175 | return getGherkinSemanticTokens( 176 | gherkinSource, 177 | (this.expressionBuilderResult?.expressionLinks || []).map((l) => l.expression) 178 | ) 179 | }) 180 | } else { 181 | connection.console.info('semanticTokens is disabled') 182 | } 183 | 184 | if (params.capabilities.textDocument?.completion?.completionItem?.snippetSupport) { 185 | connection.onCompletion((params) => { 186 | if (!this.searchIndex) return [] 187 | 188 | const doc = documents.get(params.textDocument.uri) 189 | if (!doc) return [] 190 | const gherkinSource = doc.getText() 191 | return getGherkinCompletionItems(gherkinSource, params.position, this.searchIndex).slice() 192 | }) 193 | 194 | connection.onCompletionResolve((item) => item) 195 | } else { 196 | connection.console.info('onCompletion is disabled') 197 | } 198 | 199 | if (params.capabilities.textDocument?.formatting) { 200 | connection.onDocumentFormatting((params) => { 201 | const doc = documents.get(params.textDocument.uri) 202 | if (!doc) return [] 203 | const gherkinSource = doc.getText() 204 | return getGherkinFormattingEdits(gherkinSource) 205 | }) 206 | } else { 207 | connection.console.info('onDocumentFormatting is disabled') 208 | } 209 | 210 | if (params.capabilities.textDocument?.codeAction) { 211 | connection.onCodeAction(async (params) => { 212 | const diagnostics = params.context.diagnostics 213 | if (this.expressionBuilderResult) { 214 | const settings = await this.getSettings() 215 | const links = getStepDefinitionSnippetLinks( 216 | this.expressionBuilderResult.expressionLinks.map((l) => l.locationLink) 217 | ) 218 | if (links.length === 0) { 219 | connection.console.info( 220 | `Unable to generate step definition. Please create one first manually.` 221 | ) 222 | return [] 223 | } 224 | 225 | const codeActions: CodeAction[] = [] 226 | for (const link of links) { 227 | const languageName = getLanguage(extname(link.targetUri)) 228 | if (!languageName) { 229 | connection.console.info( 230 | `Unable to generate step definition snippet for unknown extension ${link}` 231 | ) 232 | return [] 233 | } 234 | const mustacheTemplate = settings.snippetTemplates[languageName] 235 | const createFile = !(await this.files.exists(link.targetUri)) 236 | const relativePath = this.files.relativePath(link.targetUri) 237 | const codeAction = getGenerateSnippetCodeAction( 238 | diagnostics, 239 | link, 240 | relativePath, 241 | createFile, 242 | mustacheTemplate, 243 | languageName, 244 | this.expressionBuilderResult.registry 245 | ) 246 | if (codeAction) { 247 | codeActions.push(codeAction) 248 | } 249 | } 250 | return codeActions 251 | } 252 | return [] 253 | }) 254 | } else { 255 | connection.console.info('onCodeAction is disabled') 256 | } 257 | 258 | if (params.capabilities.textDocument?.definition) { 259 | connection.onDefinition((params) => { 260 | const doc = documents.get(params.textDocument.uri) 261 | if (!doc || !this.expressionBuilderResult) return [] 262 | const gherkinSource = doc.getText() 263 | return getStepDefinitionLocationLinks( 264 | gherkinSource, 265 | params.position, 266 | this.expressionBuilderResult.expressionLinks 267 | ) 268 | }) 269 | } else { 270 | connection.console.info('onDefinition is disabled') 271 | } 272 | 273 | if (params.capabilities.textDocument?.documentSymbol) { 274 | connection.onDocumentSymbol((params) => { 275 | const doc = documents.get(params.textDocument.uri) 276 | if (!doc) return [] 277 | const gherkinSource = doc.getText() 278 | const symbol = getGherkinDocumentFeatureSymbol(gherkinSource) 279 | return symbol ? [symbol] : null 280 | }) 281 | } else { 282 | connection.console.info('onDocumentSymbol is disabled') 283 | } 284 | 285 | return { 286 | capabilities: this.capabilities(), 287 | serverInfo: this.info(), 288 | } 289 | }) 290 | 291 | connection.onInitialized(() => { 292 | connection.console.info(`${this.info().name} ${this.info().version} initialized`) 293 | this.reindex().catch((err) => connection.console.error(err.message)) 294 | }) 295 | 296 | documents.listen(connection) 297 | 298 | // The content of a text document has changed. This event is emitted 299 | // when the text document is first opened or when its content has changed. 300 | documents.onDidChangeContent(async (change) => { 301 | this.scheduleReindexing() 302 | if (change.document.uri.match(/\.feature$/)) { 303 | await this.sendDiagnostics(change.document) 304 | } 305 | }) 306 | } 307 | 308 | public capabilities(): ServerCapabilities { 309 | return { 310 | textDocumentSync: TextDocumentSyncKind.Incremental, 311 | completionProvider: { 312 | resolveProvider: false, 313 | }, 314 | codeActionProvider: { 315 | resolveProvider: false, 316 | workDoneProgress: false, 317 | codeActionKinds: [CodeActionKind.QuickFix], 318 | }, 319 | workspace: { 320 | workspaceFolders: { 321 | changeNotifications: true, 322 | supported: true, 323 | }, 324 | }, 325 | semanticTokensProvider: { 326 | range: false, 327 | full: { 328 | delta: false, 329 | }, 330 | legend: { 331 | tokenTypes: semanticTokenTypes, 332 | tokenModifiers: [], 333 | }, 334 | }, 335 | documentSymbolProvider: { 336 | label: 'Cucumber', 337 | }, 338 | documentFormattingProvider: true, 339 | definitionProvider: true, 340 | } 341 | } 342 | 343 | public info(): ServerInfo { 344 | return { 345 | name: 'Cucumber Language Server', 346 | version, 347 | } 348 | } 349 | 350 | private async sendDiagnostics(textDocument: TextDocument): Promise { 351 | const diagnostics = getGherkinDiagnostics( 352 | textDocument.getText(), 353 | (this.expressionBuilderResult?.expressionLinks || []).map((l) => l.expression) 354 | ) 355 | await this.connection.sendDiagnostics({ 356 | uri: textDocument.uri, 357 | diagnostics, 358 | }) 359 | } 360 | 361 | private scheduleReindexing() { 362 | clearTimeout(this.reindexingTimeout) 363 | const timeoutMillis = 3000 364 | this.connection.console.info(`Scheduling reindexing in ${timeoutMillis} ms`) 365 | this.reindexingTimeout = setTimeout(() => { 366 | this.reindex().catch((err) => 367 | this.connection.console.error(`Failed to reindex: ${err.message}`) 368 | ) 369 | }, timeoutMillis) 370 | } 371 | 372 | private async getSettings(): Promise { 373 | try { 374 | const config = await this.connection.sendRequest(ConfigurationRequest.type, { 375 | items: [ 376 | { 377 | section: 'cucumber', 378 | }, 379 | ], 380 | }) 381 | if (config && config.length === 1) { 382 | const settings: Partial | null = config[0] 383 | 384 | return { 385 | features: getArray(settings?.features, defaultSettings.features), 386 | glue: getArray(settings?.glue, defaultSettings.glue), 387 | parameterTypes: getArray(settings?.parameterTypes, defaultSettings.parameterTypes), 388 | snippetTemplates: settings?.snippetTemplates || {}, 389 | } 390 | } else { 391 | this.connection.console.error( 392 | `The client responded with a config we cannot process: ${JSON.stringify(config, null, 2)}` 393 | ) 394 | this.connection.console.error(`Using default settings`) 395 | return defaultSettings 396 | } 397 | } catch (err) { 398 | this.connection.console.error(`Failed to request configuration: ${err.message}`) 399 | this.connection.console.error(`Using default settings`) 400 | return defaultSettings 401 | } 402 | } 403 | 404 | private async reindex(settings?: Settings) { 405 | if (!settings) { 406 | settings = await this.getSettings() 407 | } 408 | // TODO: Send WorkDoneProgressBegin notification 409 | // https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#workDoneProgress 410 | 411 | this.connection.console.info(`Reindexing ${this.rootUri}`) 412 | const gherkinSources = await loadGherkinSources(this.files, settings.features) 413 | this.connection.console.info( 414 | `* Found ${gherkinSources.length} feature file(s) in ${JSON.stringify(settings.features)}` 415 | ) 416 | const stepTexts = gherkinSources.reduce( 417 | (prev, gherkinSource) => prev.concat(buildStepTexts(gherkinSource.content)), 418 | [] 419 | ) 420 | this.connection.console.info(`* Found ${stepTexts.length} steps in those feature files`) 421 | const glueSources = await loadGlueSources(this.files, settings.glue) 422 | this.connection.console.info( 423 | `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` 424 | ) 425 | this.expressionBuilderResult = this.expressionBuilder.build( 426 | glueSources, 427 | settings.parameterTypes 428 | ) 429 | this.connection.console.info( 430 | `* Found ${this.expressionBuilderResult.parameterTypeLinks.length} parameter types in those glue files` 431 | ) 432 | for (const parameterTypeLink of this.expressionBuilderResult.parameterTypeLinks) { 433 | this.connection.console.info( 434 | ` * {${parameterTypeLink.parameterType.name}} = ${parameterTypeLink.parameterType.regexpStrings}` 435 | ) 436 | } 437 | this.connection.console.info( 438 | `* Found ${this.expressionBuilderResult.expressionLinks.length} step definitions in those glue files` 439 | ) 440 | for (const error of this.expressionBuilderResult.errors) { 441 | this.connection.console.error(`* Step Definition errors: ${error.stack}`) 442 | } 443 | 444 | // Send diagnostics for all documents now that we're updated 445 | const gherkinDocuments = this.documents.all().filter((doc) => doc.uri.match(/\.feature$/)) 446 | await Promise.all( 447 | gherkinDocuments.map((doc) => 448 | this.sendDiagnostics(doc).catch((err) => 449 | this.connection.console.error(`Error: ${err.message}`) 450 | ) 451 | ) 452 | ) 453 | // Tell the client to update all semantic tokens 454 | this.connection.languages.semanticTokens.refresh() 455 | 456 | try { 457 | const expressions = this.expressionBuilderResult.expressionLinks.map((l) => l.expression) 458 | const suggestions = buildSuggestions( 459 | this.expressionBuilderResult.registry, 460 | stepTexts, 461 | expressions 462 | ) 463 | this.connection.console.info(`* Built ${suggestions.length} suggestions for auto complete`) 464 | this.searchIndex = jsSearchIndex(suggestions) 465 | const registry = this.expressionBuilderResult.registry 466 | this.registry = registry 467 | this.expressions = expressions 468 | this.suggestions = suggestions 469 | this.onReindexed(registry, expressions, suggestions) 470 | } catch (err) { 471 | this.connection.console.error(err.stack) 472 | this.connection.console.error( 473 | 'Please report an issue at https://github.com/cucumber/language-service/issues with the above stack trace' 474 | ) 475 | } 476 | 477 | // TODO: Send WorkDoneProgressEnd notification 478 | } 479 | } 480 | 481 | function getArray(arr: readonly T[] | undefined | null, defaultArr: readonly T[]): readonly T[] { 482 | if (!Array.isArray(arr) || arr.length === 0) return defaultArr 483 | return arr 484 | } 485 | --------------------------------------------------------------------------------