From ebcc6668a7af010ad61e1d08c74cc3c4cbc295d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Thu, 11 Dec 2025 13:30:57 +0200 Subject: [PATCH 01/12] add forceReindex --- package-lock.json | 47 ++++++++++++++--------------- src/CucumberLanguageServer.ts | 2 ++ src/types.ts | 1 + test/CucumberLanguageServer.test.ts | 1 + test/standalone.test.ts | 1 + 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37c665fc..586aba8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -262,6 +262,7 @@ "version": "32.2.0", "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz", "integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==", + "peer": true, "dependencies": { "@cucumber/messages": ">=19.1.4 <28" } @@ -446,6 +447,7 @@ "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, + "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } @@ -454,6 +456,7 @@ "version": "27.2.0", "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "peer": true, "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", @@ -875,6 +878,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.3.tgz", "integrity": "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -928,6 +932,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -1111,6 +1116,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2084,6 +2090,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2154,6 +2161,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4685,6 +4693,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5660,17 +5669,6 @@ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", "dev": true }, - "node_modules/tree-sitter": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", - "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - } - }, "node_modules/tree-sitter-c-sharp": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-c-sharp/-/tree-sitter-c-sharp-0.23.1.tgz", @@ -6087,6 +6085,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6674,6 +6673,7 @@ "version": "32.2.0", "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz", "integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==", + "peer": true, "requires": { "@cucumber/messages": ">=19.1.4 <28" } @@ -6831,12 +6831,14 @@ "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, + "peer": true, "requires": {} }, "@cucumber/messages": { "version": "27.2.0", "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "peer": true, "requires": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", @@ -7148,6 +7150,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.3.tgz", "integrity": "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==", "dev": true, + "peer": true, "requires": { "undici-types": "~7.16.0" } @@ -7185,6 +7188,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -7289,7 +7293,8 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -7998,6 +8003,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8053,6 +8059,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, + "peer": true, "requires": {} }, "eslint-import-resolver-node": { @@ -9793,7 +9800,8 @@ "version": "3.7.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", - "dev": true + "dev": true, + "peer": true }, "prettier-linter-helpers": { "version": "1.0.0", @@ -10462,16 +10470,6 @@ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", "dev": true }, - "tree-sitter": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", - "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", - "optional": true, - "requires": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - } - }, "tree-sitter-c-sharp": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-c-sharp/-/tree-sitter-c-sharp-0.23.1.tgz", @@ -10721,7 +10719,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true + "dev": true, + "peer": true }, "unbox-primitive": { "version": "1.1.0", diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index b574afda..4335b844 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -82,6 +82,7 @@ const defaultSettings: Settings = { ], parameterTypes: [], snippetTemplates: {}, + forceReindex: true, } export class CucumberLanguageServer { @@ -386,6 +387,7 @@ export class CucumberLanguageServer { glue: getArray(settings?.glue, defaultSettings.glue), parameterTypes: getArray(settings?.parameterTypes, defaultSettings.parameterTypes), snippetTemplates: {}, + forceReindex: settings?.forceReindex ?? defaultSettings.forceReindex, } } else { this.connection.console.error( diff --git a/src/types.ts b/src/types.ts index ac5b44a4..e490c64b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,4 +10,5 @@ export type Settings = { glue: readonly string[] parameterTypes: readonly ParameterTypeMeta[] snippetTemplates: Readonly>> + forceReindex: boolean } diff --git a/test/CucumberLanguageServer.test.ts b/test/CucumberLanguageServer.test.ts index 1ebe0307..b7d61603 100644 --- a/test/CucumberLanguageServer.test.ts +++ b/test/CucumberLanguageServer.test.ts @@ -130,6 +130,7 @@ describe('CucumberLanguageServer', () => { glue: [`testdata/**/*.${fileExtension}`], parameterTypes: [], snippetTemplates: {}, + forceReindex: true, } const configParams: DidChangeConfigurationParams = { settings, diff --git a/test/standalone.test.ts b/test/standalone.test.ts index 2475b9a1..e4637cc3 100644 --- a/test/standalone.test.ts +++ b/test/standalone.test.ts @@ -102,6 +102,7 @@ describe('Standalone', () => { glue: ['testdata/**/*.js'], parameterTypes: [], snippetTemplates: {}, + forceReindex: true, } const configParams: DidChangeConfigurationParams = { settings, From 0d7ee54763b76fc5ff2fa42fcbbd6c1098ad8fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Thu, 11 Dec 2025 15:20:09 +0200 Subject: [PATCH 02/12] skip reindexing if setting --- src/CucumberLanguageServer.ts | 106 ++++++++++++++++++++++++++++------ src/fs.ts | 1 + 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 4335b844..276a3a6e 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -12,8 +12,10 @@ import { getStepDefinitionLocationLinks, Index, jsSearchIndex, + LanguageName, ParserAdapter, semanticTokenTypes, + Source, Suggestion, } from '@cucumber/language-service' import { @@ -92,6 +94,10 @@ export class CucumberLanguageServer { private reindexingTimeout: NodeJS.Timeout private rootUri: string private files: Files + private gherkinSourcesCache: Map> = new Map() + private glueSourcesCache: Map> = new Map() + private stepTextsCache: Map = new Map() + private glueSourceChanged = true public registry: CucumberExpressions.ParameterTypeRegistry public expressions: readonly CucumberExpressions.Expression[] = [] public suggestions: readonly Suggestion[] = [] @@ -298,9 +304,17 @@ export class CucumberLanguageServer { // The content of a text document has changed. This event is emitted // when the text document is first opened or when its content has changed. - documents.onDidChangeContent(async (change) => { - this.scheduleReindexing() - if (change.document.uri.match(/\.feature$/)) { + documents.onDidSave(async (change) => { + const uri = change.document.uri + const content = change.document.getText() + this.updateSourceCache(uri, content) + const settings = await this.getSettings() + if (!settings.forceReindex) { + this.connection.console.info('SKIPPING reindexing due to cucumber.forceReindex setting') + return + } + this.scheduleReindexing(true) + if (uri.match(/\.feature$/)) { await this.sendDiagnostics(change.document) } }) @@ -359,17 +373,32 @@ export class CucumberLanguageServer { }) } - private scheduleReindexing() { + private scheduleReindexing(useCache = false) { clearTimeout(this.reindexingTimeout) const timeoutMillis = 3000 this.connection.console.info(`Scheduling reindexing in ${timeoutMillis} ms`) this.reindexingTimeout = setTimeout(() => { - this.reindex().catch((err) => + this.reindex(undefined, useCache).catch((err) => this.connection.console.error(`Failed to reindex: ${err.message}`) ) }, timeoutMillis) } + private updateSourceCache(uri: string, content: string): void { + const ext = extname(uri) + if (ext === '.feature') { + this.gherkinSourcesCache.set(uri, { languageName: 'gherkin', uri, content }) + // Update stepTexts cache for this file only + this.stepTextsCache.set(uri, buildStepTexts(content)) + } else { + const languageName = getLanguage(ext) + if (languageName) { + this.glueSourcesCache.set(uri, { languageName, uri, content }) + this.glueSourceChanged = true + } + } + } + private async getSettings(): Promise { try { const config = await this.connection.sendRequest(ConfigurationRequest.type, { @@ -403,31 +432,74 @@ export class CucumberLanguageServer { } } - private async reindex(settings?: Settings) { + private async reindex(settings?: Settings, useCache = false) { if (!settings) { settings = await this.getSettings() } // TODO: Send WorkDoneProgressBegin notification // https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#workDoneProgress - this.connection.console.info(`Reindexing ${this.rootUri}`) - const gherkinSources = await loadGherkinSources(this.files, settings.features) + this.connection.console.info(`Reindexing ${this.rootUri}, useCache: ${useCache}`) + + let gherkinSources: readonly Source<'gherkin'>[] + let glueSources: readonly Source[] + + if (useCache && this.gherkinSourcesCache.size > 0) { + gherkinSources = Array.from(this.gherkinSourcesCache.values()) + glueSources = Array.from(this.glueSourcesCache.values()) + this.connection.console.info(`* Using cached sources`) + } else { + gherkinSources = await loadGherkinSources(this.files, settings.features) + glueSources = await loadGlueSources(this.files, settings.glue) + // Populate caches + this.gherkinSourcesCache.clear() + this.glueSourcesCache.clear() + for (const source of gherkinSources) { + this.gherkinSourcesCache.set(source.uri, source) + } + for (const source of glueSources) { + this.glueSourcesCache.set(source.uri, source) + } + } + this.connection.console.info( `* Found ${gherkinSources.length} feature file(s) in ${JSON.stringify(settings.features)}` ) - const stepTexts = gherkinSources.reduce( - (prev, gherkinSource) => prev.concat(buildStepTexts(gherkinSource.content)), - [] - ) + + // Build stepTexts using cache when possible + let stepTexts: readonly string[] + if (useCache && this.stepTextsCache.size > 0) { + // Merge all cached stepTexts + stepTexts = Array.from(this.stepTextsCache.values()).flat() + this.connection.console.info(`* Using cached stepTexts`) + } else { + // Build stepTexts for each gherkin source and populate cache + this.stepTextsCache.clear() + const allStepTexts: string[] = [] + for (const source of gherkinSources) { + const fileStepTexts = buildStepTexts(source.content) + this.stepTextsCache.set(source.uri, fileStepTexts) + allStepTexts.push(...fileStepTexts) + } + stepTexts = allStepTexts + } + this.connection.console.info(`* Found ${stepTexts.length} steps in those feature files`) - const glueSources = await loadGlueSources(this.files, settings.glue) this.connection.console.info( `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` ) - this.expressionBuilderResult = this.expressionBuilder.build( - glueSources, - settings.parameterTypes - ) + + // Only rebuild expressions if glue sources changed or we don't have a result yet + if (this.glueSourceChanged || !this.expressionBuilderResult) { + this.expressionBuilderResult = this.expressionBuilder.build( + glueSources, + settings.parameterTypes + ) + this.glueSourceChanged = false + this.connection.console.info(`* Rebuilt expressions from glue sources`) + } else { + this.connection.console.info(`* Using cached expressions (no glue changes)`) + } this.connection.console.info( `* Found ${this.expressionBuilderResult.parameterTypeLinks.length} parameter types in those glue files` ) diff --git a/src/fs.ts b/src/fs.ts index 7254604b..39216228 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -12,6 +12,7 @@ export const glueExtByLanguageName: Record = { python: ['.py'], rust: ['.rs'], go: ['.go'], + scala: ['.scala'], } type ExtLangEntry = [string, LanguageName] From 3308811a22bc6d59b6ee3255db8919c775710c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Thu, 11 Dec 2025 17:34:55 +0200 Subject: [PATCH 03/12] add cucumber/forceReindex --- src/CucumberLanguageServer.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 276a3a6e..898d6b20 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -300,6 +300,18 @@ export class CucumberLanguageServer { this.reindex().catch((err) => connection.console.error(err.message)) }) + // Handle custom force reindex request from client + connection.onRequest('cucumber/forceReindex', async () => { + connection.console.info('Received cucumber/forceReindex request') + try { + await this.reindex(undefined, false) + return { success: true } + } catch (err) { + connection.console.error(`Force reindex failed: ${err.message}`) + return { success: false, error: err.message } + } + }) + documents.listen(connection) // The content of a text document has changed. This event is emitted From 7cd1f32043e7f1b33d79129be557d50f616706b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Tue, 3 Feb 2026 12:09:01 +0200 Subject: [PATCH 04/12] store glue and gherkin in map --- .tool-versions | 1 + src/CucumberLanguageServer.ts | 164 ++++++++++++++-------------------- src/fs.ts | 44 ++++++--- 3 files changed, 103 insertions(+), 106 deletions(-) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..d0b9920e --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 24.11.0 \ No newline at end of file diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 898d6b20..4d2f1306 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -32,7 +32,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument' import { buildStepTexts } from './buildStepTexts.js' import { extname, Files } from './Files.js' -import { getLanguage, loadGherkinSources, loadGlueSources } from './fs.js' +import { getLanguage, loadGherkinSources, loadGlueSources, SourceCache } from './fs.js' import { getStepDefinitionSnippetLinks } from './getStepDefinitionSnippetLinks.js' import { Settings } from './types.js' import { version } from './version.js' @@ -84,7 +84,7 @@ const defaultSettings: Settings = { ], parameterTypes: [], snippetTemplates: {}, - forceReindex: true, + forceReindex: false, } export class CucumberLanguageServer { @@ -94,10 +94,10 @@ export class CucumberLanguageServer { private reindexingTimeout: NodeJS.Timeout private rootUri: string private files: Files - private gherkinSourcesCache: Map> = new Map() - private glueSourcesCache: Map> = new Map() - private stepTextsCache: Map = new Map() - private glueSourceChanged = true + private gherkinSourcesCacheMap: SourceCache<'gherkin'> = new Map() + private glueSourcesCacheMap: SourceCache = new Map() + private suggestionsCache: readonly Suggestion[] | undefined = undefined + private expressionBuilderResultCache: ExpressionBuilderResult | undefined = undefined public registry: CucumberExpressions.ParameterTypeRegistry public expressions: readonly CucumberExpressions.Expression[] = [] public suggestions: readonly Suggestion[] = [] @@ -143,7 +143,7 @@ export class CucumberLanguageServer { if (params.capabilities.workspace?.configuration) { connection.onDidChangeConfiguration((params) => { this.connection.console.info(`Client sent workspace/configuration`) - this.reindex(params.settings).catch((err) => { + this.reindex(undefined, params.settings).catch((err) => { connection.console.error(`Failed to reindex: ${err.message}`) }) }) @@ -304,7 +304,7 @@ export class CucumberLanguageServer { connection.onRequest('cucumber/forceReindex', async () => { connection.console.info('Received cucumber/forceReindex request') try { - await this.reindex(undefined, false) + await this.reindex(undefined, undefined) return { success: true } } catch (err) { connection.console.error(`Force reindex failed: ${err.message}`) @@ -317,16 +317,8 @@ export class CucumberLanguageServer { // The content of a text document has changed. This event is emitted // when the text document is first opened or when its content has changed. documents.onDidSave(async (change) => { - const uri = change.document.uri - const content = change.document.getText() - this.updateSourceCache(uri, content) - const settings = await this.getSettings() - if (!settings.forceReindex) { - this.connection.console.info('SKIPPING reindexing due to cucumber.forceReindex setting') - return - } - this.scheduleReindexing(true) - if (uri.match(/\.feature$/)) { + this.scheduleReindexing(change.document) + if (change.document.uri.match(/\.feature$/)) { await this.sendDiagnostics(change.document) } }) @@ -385,32 +377,18 @@ export class CucumberLanguageServer { }) } - private scheduleReindexing(useCache = false) { + private scheduleReindexing(document: TextDocument) { clearTimeout(this.reindexingTimeout) const timeoutMillis = 3000 + this.connection.console.info(`DEBUG: change: ${JSON.stringify(document, null, 2)}`) this.connection.console.info(`Scheduling reindexing in ${timeoutMillis} ms`) this.reindexingTimeout = setTimeout(() => { - this.reindex(undefined, useCache).catch((err) => + this.reindex(document).catch((err) => this.connection.console.error(`Failed to reindex: ${err.message}`) ) }, timeoutMillis) } - private updateSourceCache(uri: string, content: string): void { - const ext = extname(uri) - if (ext === '.feature') { - this.gherkinSourcesCache.set(uri, { languageName: 'gherkin', uri, content }) - // Update stepTexts cache for this file only - this.stepTextsCache.set(uri, buildStepTexts(content)) - } else { - const languageName = getLanguage(ext) - if (languageName) { - this.glueSourcesCache.set(uri, { languageName, uri, content }) - this.glueSourceChanged = true - } - } - } - private async getSettings(): Promise { try { const config = await this.connection.sendRequest(ConfigurationRequest.type, { @@ -427,8 +405,8 @@ export class CucumberLanguageServer { features: getArray(settings?.features, defaultSettings.features), glue: getArray(settings?.glue, defaultSettings.glue), parameterTypes: getArray(settings?.parameterTypes, defaultSettings.parameterTypes), - snippetTemplates: {}, - forceReindex: settings?.forceReindex ?? defaultSettings.forceReindex, + snippetTemplates: settings?.snippetTemplates || {}, + forceReindex: settings?.forceReindex || false, } } else { this.connection.console.error( @@ -444,85 +422,74 @@ export class CucumberLanguageServer { } } - private async reindex(settings?: Settings, useCache = false) { + private async reindex(document?: TextDocument, settings?: Settings) { if (!settings) { settings = await this.getSettings() } // TODO: Send WorkDoneProgressBegin notification // https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#workDoneProgress - this.connection.console.info(`Reindexing ${this.rootUri}, useCache: ${useCache}`) - - let gherkinSources: readonly Source<'gherkin'>[] - let glueSources: readonly Source[] - - if (useCache && this.gherkinSourcesCache.size > 0) { - gherkinSources = Array.from(this.gherkinSourcesCache.values()) - glueSources = Array.from(this.glueSourcesCache.values()) - this.connection.console.info(`* Using cached sources`) - } else { - gherkinSources = await loadGherkinSources(this.files, settings.features) - glueSources = await loadGlueSources(this.files, settings.glue) - // Populate caches - this.gherkinSourcesCache.clear() - this.glueSourcesCache.clear() - for (const source of gherkinSources) { - this.gherkinSourcesCache.set(source.uri, source) - } - for (const source of glueSources) { - this.glueSourcesCache.set(source.uri, source) - } + this.connection.console.info(`Reindexing ${this.rootUri}`) + this.connection.console.info(`Settings: ${JSON.stringify(settings, null, 2)}`) + this.connection.console.info(`Files: ${JSON.stringify(this.files, null, 2)}`) + let gherkinSources: readonly Source<'gherkin'>[] = [] + if (this.gherkinSourcesCacheMap.size === 0) { + this.connection.console.info(`Loading gherkin sources from scratch`) + gherkinSources = await loadGherkinSources( + this.files, + settings.features, + this.gherkinSourcesCacheMap + ) } - this.connection.console.info( `* Found ${gherkinSources.length} feature file(s) in ${JSON.stringify(settings.features)}` ) - - // Build stepTexts using cache when possible - let stepTexts: readonly string[] - if (useCache && this.stepTextsCache.size > 0) { - // Merge all cached stepTexts - stepTexts = Array.from(this.stepTextsCache.values()).flat() - this.connection.console.info(`* Using cached stepTexts`) - } else { - // Build stepTexts for each gherkin source and populate cache - this.stepTextsCache.clear() - const allStepTexts: string[] = [] - for (const source of gherkinSources) { - const fileStepTexts = buildStepTexts(source.content) - this.stepTextsCache.set(source.uri, fileStepTexts) - allStepTexts.push(...fileStepTexts) - } - stepTexts = allStepTexts - } - + this.connection.console.info( + `* Gherkin sources sample: \n ${JSON.stringify(gherkinSources[0].content, null, 2)}` + ) + const stepTexts = gherkinSources.reduce( + (prev, gherkinSource) => prev.concat(buildStepTexts(gherkinSource.content)), + [] + ) this.connection.console.info(`* Found ${stepTexts.length} steps in those feature files`) + this.connection.console.info(`* Steptexts sample: \n ${JSON.stringify(stepTexts[0], null, 2)}`) + this.connection.console.info(`Loading glue sources from scratch`) + let glueSources: readonly Source[] = [] + if (this.glueSourcesCacheMap.size === 0) { + glueSources = await loadGlueSources(this.files, settings.glue, this.glueSourcesCacheMap) + } this.connection.console.info( `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` ) + this.connection.console.info( + `* Glue sources sample: \n ${JSON.stringify(glueSources[0], null, 2)}` + ) - // Only rebuild expressions if glue sources changed or we don't have a result yet - if (this.glueSourceChanged || !this.expressionBuilderResult) { - this.expressionBuilderResult = this.expressionBuilder.build( + if (this.expressionBuilderResultCache === undefined) { + this.connection.console.info(`Building expression builder result from scratch`) + this.expressionBuilderResultCache = this.expressionBuilder.build( glueSources, settings.parameterTypes ) - this.glueSourceChanged = false - this.connection.console.info(`* Rebuilt expressions from glue sources`) - } else { - this.connection.console.info(`* Using cached expressions (no glue changes)`) } + this.expressionBuilderResult = this.expressionBuilderResultCache this.connection.console.info( `* Found ${this.expressionBuilderResult.parameterTypeLinks.length} parameter types in those glue files` ) - for (const parameterTypeLink of this.expressionBuilderResult.parameterTypeLinks) { - this.connection.console.info( - ` * {${parameterTypeLink.parameterType.name}} = ${parameterTypeLink.parameterType.regexpStrings}` - ) - } + // for (const parameterTypeLink of this.expressionBuilderResult.parameterTypeLinks) { + // this.connection.console.info( + // ` * {${parameterTypeLink.parameterType.name}} = ${parameterTypeLink.parameterType.regexpStrings}` + // ) + // } + this.connection.console.info( + `* Parameter type links sample: \n ${JSON.stringify(this.expressionBuilderResult.parameterTypeLinks[0], null, 2)}` + ) this.connection.console.info( `* Found ${this.expressionBuilderResult.expressionLinks.length} step definitions in those glue files` ) + this.connection.console.info( + ` Expression link: sample: \n ${JSON.stringify(this.expressionBuilderResult.expressionLinks[0], null, 2)}` + ) for (const error of this.expressionBuilderResult.errors) { this.connection.console.error(`* Step Definition errors: ${error.stack}`) } @@ -541,11 +508,16 @@ export class CucumberLanguageServer { try { const expressions = this.expressionBuilderResult.expressionLinks.map((l) => l.expression) - const suggestions = buildSuggestions( - this.expressionBuilderResult.registry, - stepTexts, - expressions - ) + if (this.suggestionsCache === undefined) { + this.connection.console.info(`Building suggestions from scratch`) + this.suggestionsCache = buildSuggestions( + this.expressionBuilderResult.registry, + stepTexts, + expressions + ) + } + const suggestions = this.suggestionsCache + this.connection.console.info(`DEBUG: suggestions: ${JSON.stringify(suggestions[0], null, 2)}`) this.connection.console.info(`* Built ${suggestions.length} suggestions for auto complete`) this.searchIndex = jsSearchIndex(suggestions) const registry = this.expressionBuilderResult.registry diff --git a/src/fs.ts b/src/fs.ts index 39216228..53d67a39 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,4 +1,5 @@ import { LanguageName, Source } from '@cucumber/language-service' +import { createHash } from 'crypto' import { extname, Files } from './Files.js' @@ -28,9 +29,11 @@ const glueExtensions = new Set(Object.keys(glueLanguageNameByExt)) export async function loadGlueSources( files: Files, - globs: readonly string[] + globs: readonly string[], + cache: SourceCache = new Map() + // changedFile?: TextDocument ): Promise[]> { - return loadSources(files, globs, glueExtensions, glueLanguageNameByExt) + return loadSources(files, globs, glueExtensions, glueLanguageNameByExt, cache) } export function getLanguage(ext: string): LanguageName | undefined { @@ -39,13 +42,25 @@ export function getLanguage(ext: string): LanguageName | undefined { export async function loadGherkinSources( files: Files, - globs: readonly string[] + globs: readonly string[], + cache: SourceCache<'gherkin'> = new Map() ): Promise[]> { - return loadSources(files, globs, new Set(['.feature']), { '.feature': 'gherkin' }) + return loadSources(files, globs, new Set(['.feature']), { '.feature': 'gherkin' }, cache) } type LanguageNameByExt = Record +export interface SourceCacheEntry { + source: Source + digest: string +} + +export type SourceCache = Map> + +function computeDigest(content: string): string { + return createHash('sha256').update(content).digest('hex') +} + export async function findUris(files: Files, globs: readonly string[]): Promise { // Run all the globs in parallel const urisPromises = globs.reduce[]>((prev, glob) => { @@ -62,25 +77,34 @@ async function loadSources( files: Files, globs: readonly string[], extensions: Set, - languageNameByExt: LanguageNameByExt + languageNameByExt: LanguageNameByExt, + cache: SourceCache ): Promise[]> { const uris = await findUris(files, globs) - return Promise.all( + const sources = await Promise.all( uris .filter((uri) => extensions.has(extname(uri))) .map>>( (uri) => new Promise>((resolve) => { const languageName = languageNameByExt[extname(uri)] - return files.readFile(uri).then((content) => - resolve({ + return files.readFile(uri).then((content) => { + const source: Source = { languageName, content, uri, - }) - ) + } + + // Compute digest and store in cache + const digest = computeDigest(content) + cache.set(uri, { source, digest }) + + resolve(source) + }) }) ) ) + + return sources } From d64c7095f8d02401b789c5dac5ce5e6df06ad090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Tue, 3 Feb 2026 16:45:29 +0200 Subject: [PATCH 05/12] glue reload incremental --- package-lock.json | 35 +++++++++++++++++++ package.json | 2 ++ src/CucumberLanguageServer.ts | 63 ++++++++++++++++++++++++++++------- src/fs.ts | 53 +++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 586aba8b..bea23f2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@cucumber/gherkin-utils": "^10.0.0", "@cucumber/language-service": "^1.7.0", "fast-glob": "3.3.3", + "micromatch": "^4.0.8", "source-map-support": "0.5.21", "vscode-languageserver": "8.0.2", "vscode-languageserver-textdocument": "1.0.12", @@ -22,6 +23,7 @@ }, "devDependencies": { "@cucumber/cucumber": "12.3.0", + "@types/micromatch": "^4.0.10", "@types/mocha": "10.0.10", "@types/node": "24.10.3", "@typescript-eslint/eslint-plugin": "7.18.0", @@ -851,6 +853,13 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "node_modules/@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/js-search": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/@types/js-search/-/js-search-1.4.4.tgz", @@ -862,6 +871,16 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/micromatch": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz", + "integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -4086,6 +4105,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -7123,6 +7143,12 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "dev": true + }, "@types/js-search": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/@types/js-search/-/js-search-1.4.4.tgz", @@ -7134,6 +7160,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/micromatch": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz", + "integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==", + "dev": true, + "requires": { + "@types/braces": "*" + } + }, "@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", diff --git a/package.json b/package.json index 65f7de46..7beb9ed2 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ }, "devDependencies": { "@cucumber/cucumber": "12.3.0", + "@types/micromatch": "^4.0.10", "@types/mocha": "10.0.10", "@types/node": "24.10.3", "@typescript-eslint/eslint-plugin": "7.18.0", @@ -96,6 +97,7 @@ "@cucumber/gherkin-utils": "^10.0.0", "@cucumber/language-service": "^1.7.0", "fast-glob": "3.3.3", + "micromatch": "^4.0.8", "source-map-support": "0.5.21", "vscode-languageserver": "8.0.2", "vscode-languageserver-textdocument": "1.0.12", diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 4d2f1306..08347c94 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -18,6 +18,7 @@ import { Source, Suggestion, } from '@cucumber/language-service' +import * as micromatch from 'micromatch' import { CodeAction, CodeActionKind, @@ -32,7 +33,14 @@ import { TextDocument } from 'vscode-languageserver-textdocument' import { buildStepTexts } from './buildStepTexts.js' import { extname, Files } from './Files.js' -import { getLanguage, loadGherkinSources, loadGlueSources, SourceCache } from './fs.js' +import { + getLanguage, + loadGherkinSources, + loadGlueSources, + SourceCache, + updateGherkinSource, + updateGlueSource, +} from './fs.js' import { getStepDefinitionSnippetLinks } from './getStepDefinitionSnippetLinks.js' import { Settings } from './types.js' import { version } from './version.js' @@ -422,6 +430,14 @@ export class CucumberLanguageServer { } } + private async getCachedGherkinSources(): Promise[]> { + return Array.from(this.gherkinSourcesCacheMap.values()).map((entry) => entry.source) + } + + private async getCachedGlueSources(): Promise[]> { + return Array.from(this.glueSourcesCacheMap.values()).map((entry) => entry.source) + } + private async reindex(document?: TextDocument, settings?: Settings) { if (!settings) { settings = await this.getSettings() @@ -432,15 +448,23 @@ export class CucumberLanguageServer { this.connection.console.info(`Reindexing ${this.rootUri}`) this.connection.console.info(`Settings: ${JSON.stringify(settings, null, 2)}`) this.connection.console.info(`Files: ${JSON.stringify(this.files, null, 2)}`) - let gherkinSources: readonly Source<'gherkin'>[] = [] - if (this.gherkinSourcesCacheMap.size === 0) { + + if (document) { + if (document.uri.match(/\.feature$/)) { + this.connection.console.info(`Updating gherkin source ${document.uri}`) + updateGherkinSource( + { uri: document.uri, content: document.getText() }, + this.files, + this.gherkinSourcesCacheMap + ) + } + } else { this.connection.console.info(`Loading gherkin sources from scratch`) - gherkinSources = await loadGherkinSources( - this.files, - settings.features, - this.gherkinSourcesCacheMap - ) + await loadGherkinSources(this.files, settings.features, this.gherkinSourcesCacheMap) } + + const gherkinSources = await this.getCachedGherkinSources() + this.connection.console.info( `* Found ${gherkinSources.length} feature file(s) in ${JSON.stringify(settings.features)}` ) @@ -453,11 +477,26 @@ export class CucumberLanguageServer { ) this.connection.console.info(`* Found ${stepTexts.length} steps in those feature files`) this.connection.console.info(`* Steptexts sample: \n ${JSON.stringify(stepTexts[0], null, 2)}`) - this.connection.console.info(`Loading glue sources from scratch`) - let glueSources: readonly Source[] = [] - if (this.glueSourcesCacheMap.size === 0) { - glueSources = await loadGlueSources(this.files, settings.glue, this.glueSourcesCacheMap) + + let newGlueSource: Source | undefined = undefined + if (document) { + // Check if document.uri matches any of the glue glob patterns + const relativePath = this.files.relativePath(document.uri) + + if (micromatch.isMatch(relativePath, settings.glue, { contains: true })) { + this.connection.console.info(`Updating glue source ${document.uri}`) + newGlueSource = await updateGlueSource( + { uri: document.uri, content: document.getText() }, + this.files, + this.glueSourcesCacheMap + ) + } + } else { + this.connection.console.info(`Loading glue sources from scratch`) + await loadGlueSources(this.files, settings.glue, this.glueSourcesCacheMap) } + const glueSources = await this.getCachedGlueSources() + this.connection.console.info(`DEBUG: newGlueSource: ${JSON.stringify(newGlueSource, null, 2)}`) this.connection.console.info( `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` ) diff --git a/src/fs.ts b/src/fs.ts index 53d67a39..a5e23ba8 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -73,6 +73,59 @@ export async function findUris(files: Files, globs: readonly string[]): Promise< return [...new Set(uris).values()].sort() } +export interface TextDocument { + uri: string + content: string +} + +async function updateSourceInternal( + document: TextDocument, + files: Files, + sourcesCacheMap: SourceCache, + languageName: L +): Promise> { + const content = await files.readFile(document.uri) + const digest = computeDigest(content) + + if (sourcesCacheMap.has(document.uri)) { + const cached = sourcesCacheMap.get(document.uri) + if (cached && cached.digest === digest) { + return cached.source + } + } + + const source: Source = { + languageName, + uri: document.uri, + content, + } + + sourcesCacheMap.set(document.uri, { source, digest }) + return source +} + +export async function updateGherkinSource( + document: TextDocument, + files: Files, + sourcesCacheMap: SourceCache<'gherkin'> +): Promise> { + return updateSourceInternal(document, files, sourcesCacheMap, 'gherkin') +} + +export async function updateGlueSource( + document: TextDocument, + files: Files, + sourcesCacheMap: SourceCache +): Promise> { + const ext = extname(document.uri) + const languageName = getLanguage(ext) + if (!languageName) { + throw new Error(`Unsupported glue file extension: ${ext} for ${document.uri}`) + } + + return updateSourceInternal(document, files, sourcesCacheMap, languageName) +} + async function loadSources( files: Files, globs: readonly string[], From 726b78dd649cfaacc72ad66c60e9eabbcd7f2ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Wed, 4 Feb 2026 14:42:46 +0200 Subject: [PATCH 06/12] use set for steptexts --- src/CucumberLanguageServer.ts | 37 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 08347c94..b705fd3f 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -104,7 +104,7 @@ export class CucumberLanguageServer { private files: Files private gherkinSourcesCacheMap: SourceCache<'gherkin'> = new Map() private glueSourcesCacheMap: SourceCache = new Map() - private suggestionsCache: readonly Suggestion[] | undefined = undefined + private suggestionsCache: readonly Suggestion[] = [] private expressionBuilderResultCache: ExpressionBuilderResult | undefined = undefined public registry: CucumberExpressions.ParameterTypeRegistry public expressions: readonly CucumberExpressions.Expression[] = [] @@ -471,12 +471,16 @@ export class CucumberLanguageServer { this.connection.console.info( `* Gherkin sources sample: \n ${JSON.stringify(gherkinSources[0].content, null, 2)}` ) - const stepTexts = gherkinSources.reduce( - (prev, gherkinSource) => prev.concat(buildStepTexts(gherkinSource.content)), - [] + const stepTexts = gherkinSources.reduce>((prev, gherkinSource) => { + for (const stepText of buildStepTexts(gherkinSource.content)) { + prev.add(stepText) + } + return prev + }, new Set()) + this.connection.console.info(`* Found ${stepTexts.size} steps in those feature files`) + this.connection.console.info( + `* Steptexts sample: \n ${JSON.stringify(stepTexts.values().next().value, null, 2)}` ) - this.connection.console.info(`* Found ${stepTexts.length} steps in those feature files`) - this.connection.console.info(`* Steptexts sample: \n ${JSON.stringify(stepTexts[0], null, 2)}`) let newGlueSource: Source | undefined = undefined if (document) { @@ -511,6 +515,13 @@ export class CucumberLanguageServer { settings.parameterTypes ) } + if (newGlueSource) { + this.connection.console.info(`Adding new glue source to expression builder result`) + this.expressionBuilderResultCache = this.expressionBuilder.rebuild( + this.expressionBuilderResultCache, + [newGlueSource] + ) + } this.expressionBuilderResult = this.expressionBuilderResultCache this.connection.console.info( `* Found ${this.expressionBuilderResult.parameterTypeLinks.length} parameter types in those glue files` @@ -547,14 +558,12 @@ export class CucumberLanguageServer { try { const expressions = this.expressionBuilderResult.expressionLinks.map((l) => l.expression) - if (this.suggestionsCache === undefined) { - this.connection.console.info(`Building suggestions from scratch`) - this.suggestionsCache = buildSuggestions( - this.expressionBuilderResult.registry, - stepTexts, - expressions - ) - } + this.connection.console.info(`Building suggestions from scratch`) + this.suggestionsCache = buildSuggestions( + this.expressionBuilderResult.registry, + stepTexts, + expressions + ) const suggestions = this.suggestionsCache this.connection.console.info(`DEBUG: suggestions: ${JSON.stringify(suggestions[0], null, 2)}`) this.connection.console.info(`* Built ${suggestions.length} suggestions for auto complete`) From 37309d67483196abcf093108defc3085b2b9ffea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Thu, 5 Feb 2026 15:46:49 +0200 Subject: [PATCH 07/12] sourcelinks caching + optout from suggestions --- src/CucumberLanguageServer.ts | 69 +++++++++++++---------------- src/types.ts | 1 + test/CucumberLanguageServer.test.ts | 1 + test/standalone.test.ts | 1 + 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index b705fd3f..d2852170 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -25,6 +25,7 @@ import { ConfigurationRequest, Connection, DidChangeConfigurationNotification, + LocationLink, ServerCapabilities, TextDocuments, TextDocumentSyncKind, @@ -93,6 +94,7 @@ const defaultSettings: Settings = { parameterTypes: [], snippetTemplates: {}, forceReindex: false, + buildSuggestions: true, } export class CucumberLanguageServer { @@ -187,10 +189,7 @@ export class CucumberLanguageServer { const doc = documents.get(semanticTokenParams.textDocument.uri) if (!doc) return { data: [] } const gherkinSource = doc.getText() - return getGherkinSemanticTokens( - gherkinSource, - (this.expressionBuilderResult?.expressionLinks || []).map((l) => l.expression) - ) + return getGherkinSemanticTokens(gherkinSource, this.getExpressionsFromLinks()) }) } else { connection.console.info('semanticTokens is disabled') @@ -227,9 +226,7 @@ export class CucumberLanguageServer { const diagnostics = params.context.diagnostics if (this.expressionBuilderResult) { const settings = await this.getSettings() - const links = getStepDefinitionSnippetLinks( - this.expressionBuilderResult.expressionLinks.map((l) => l.locationLink) - ) + const links = getStepDefinitionSnippetLinks(this.getLocationLinksFromLinks()) if (links.length === 0) { connection.console.info( `Unable to generate step definition. Please create one first manually.` @@ -278,7 +275,7 @@ export class CucumberLanguageServer { return getStepDefinitionLocationLinks( gherkinSource, params.position, - this.expressionBuilderResult.expressionLinks + Array.from(this.expressionBuilderResult.expressionLinks.values()).flat() ) }) } else { @@ -377,7 +374,7 @@ export class CucumberLanguageServer { private async sendDiagnostics(textDocument: TextDocument): Promise { const diagnostics = getGherkinDiagnostics( textDocument.getText(), - (this.expressionBuilderResult?.expressionLinks || []).map((l) => l.expression) + this.getExpressionsFromLinks() ) await this.connection.sendDiagnostics({ uri: textDocument.uri, @@ -387,8 +384,7 @@ export class CucumberLanguageServer { private scheduleReindexing(document: TextDocument) { clearTimeout(this.reindexingTimeout) - const timeoutMillis = 3000 - this.connection.console.info(`DEBUG: change: ${JSON.stringify(document, null, 2)}`) + const timeoutMillis = 1000 this.connection.console.info(`Scheduling reindexing in ${timeoutMillis} ms`) this.reindexingTimeout = setTimeout(() => { this.reindex(document).catch((err) => @@ -415,6 +411,7 @@ export class CucumberLanguageServer { parameterTypes: getArray(settings?.parameterTypes, defaultSettings.parameterTypes), snippetTemplates: settings?.snippetTemplates || {}, forceReindex: settings?.forceReindex || false, + buildSuggestions: settings?.buildSuggestions || false, } } else { this.connection.console.error( @@ -438,6 +435,18 @@ export class CucumberLanguageServer { return Array.from(this.glueSourcesCacheMap.values()).map((entry) => entry.source) } + private getExpressionsFromLinks(): readonly CucumberExpressions.Expression[] { + return Array.from(this.expressionBuilderResult?.expressionLinks.values() || []).flatMap( + (links) => links.map((l) => l.expression) + ) + } + + private getLocationLinksFromLinks(): readonly LocationLink[] { + return Array.from(this.expressionBuilderResult?.expressionLinks.values() || []).flatMap( + (links) => links.map((l) => l.locationLink) + ) + } + private async reindex(document?: TextDocument, settings?: Settings) { if (!settings) { settings = await this.getSettings() @@ -447,7 +456,6 @@ export class CucumberLanguageServer { this.connection.console.info(`Reindexing ${this.rootUri}`) this.connection.console.info(`Settings: ${JSON.stringify(settings, null, 2)}`) - this.connection.console.info(`Files: ${JSON.stringify(this.files, null, 2)}`) if (document) { if (document.uri.match(/\.feature$/)) { @@ -468,19 +476,13 @@ export class CucumberLanguageServer { this.connection.console.info( `* Found ${gherkinSources.length} feature file(s) in ${JSON.stringify(settings.features)}` ) - this.connection.console.info( - `* Gherkin sources sample: \n ${JSON.stringify(gherkinSources[0].content, null, 2)}` - ) const stepTexts = gherkinSources.reduce>((prev, gherkinSource) => { for (const stepText of buildStepTexts(gherkinSource.content)) { prev.add(stepText) } return prev }, new Set()) - this.connection.console.info(`* Found ${stepTexts.size} steps in those feature files`) - this.connection.console.info( - `* Steptexts sample: \n ${JSON.stringify(stepTexts.values().next().value, null, 2)}` - ) + this.connection.console.info(`* Found ${stepTexts.size} unique steps in those feature files`) let newGlueSource: Source | undefined = undefined if (document) { @@ -500,13 +502,9 @@ export class CucumberLanguageServer { await loadGlueSources(this.files, settings.glue, this.glueSourcesCacheMap) } const glueSources = await this.getCachedGlueSources() - this.connection.console.info(`DEBUG: newGlueSource: ${JSON.stringify(newGlueSource, null, 2)}`) this.connection.console.info( `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` ) - this.connection.console.info( - `* Glue sources sample: \n ${JSON.stringify(glueSources[0], null, 2)}` - ) if (this.expressionBuilderResultCache === undefined) { this.connection.console.info(`Building expression builder result from scratch`) @@ -524,7 +522,7 @@ export class CucumberLanguageServer { } this.expressionBuilderResult = this.expressionBuilderResultCache this.connection.console.info( - `* Found ${this.expressionBuilderResult.parameterTypeLinks.length} parameter types in those glue files` + `* Found ${this.expressionBuilderResult.parameterTypeLinks.size} parameter types in those glue files` ) // for (const parameterTypeLink of this.expressionBuilderResult.parameterTypeLinks) { // this.connection.console.info( @@ -532,13 +530,7 @@ export class CucumberLanguageServer { // ) // } this.connection.console.info( - `* Parameter type links sample: \n ${JSON.stringify(this.expressionBuilderResult.parameterTypeLinks[0], null, 2)}` - ) - this.connection.console.info( - `* Found ${this.expressionBuilderResult.expressionLinks.length} step definitions in those glue files` - ) - this.connection.console.info( - ` Expression link: sample: \n ${JSON.stringify(this.expressionBuilderResult.expressionLinks[0], null, 2)}` + `* Found ${this.expressionBuilderResult.expressionLinks.size} step definitions in those glue files` ) for (const error of this.expressionBuilderResult.errors) { this.connection.console.error(`* Step Definition errors: ${error.stack}`) @@ -557,15 +549,16 @@ export class CucumberLanguageServer { this.connection.languages.semanticTokens.refresh() try { - const expressions = this.expressionBuilderResult.expressionLinks.map((l) => l.expression) + const expressions = this.getExpressionsFromLinks() this.connection.console.info(`Building suggestions from scratch`) - this.suggestionsCache = buildSuggestions( - this.expressionBuilderResult.registry, - stepTexts, - expressions - ) + if (settings.buildSuggestions) { + this.suggestionsCache = buildSuggestions( + this.expressionBuilderResult.registry, + stepTexts, + expressions + ) + } const suggestions = this.suggestionsCache - this.connection.console.info(`DEBUG: suggestions: ${JSON.stringify(suggestions[0], null, 2)}`) this.connection.console.info(`* Built ${suggestions.length} suggestions for auto complete`) this.searchIndex = jsSearchIndex(suggestions) const registry = this.expressionBuilderResult.registry diff --git a/src/types.ts b/src/types.ts index e490c64b..226cb502 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,4 +11,5 @@ export type Settings = { parameterTypes: readonly ParameterTypeMeta[] snippetTemplates: Readonly>> forceReindex: boolean + buildSuggestions: boolean } diff --git a/test/CucumberLanguageServer.test.ts b/test/CucumberLanguageServer.test.ts index b7d61603..d6c6f1a3 100644 --- a/test/CucumberLanguageServer.test.ts +++ b/test/CucumberLanguageServer.test.ts @@ -131,6 +131,7 @@ describe('CucumberLanguageServer', () => { parameterTypes: [], snippetTemplates: {}, forceReindex: true, + buildSuggestions: true, } const configParams: DidChangeConfigurationParams = { settings, diff --git a/test/standalone.test.ts b/test/standalone.test.ts index e4637cc3..5742079c 100644 --- a/test/standalone.test.ts +++ b/test/standalone.test.ts @@ -103,6 +103,7 @@ describe('Standalone', () => { parameterTypes: [], snippetTemplates: {}, forceReindex: true, + buildSuggestions: true, } const configParams: DidChangeConfigurationParams = { settings, From 1fc9379ddece29a3c92a51f7e7538aa833aa7803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Tue, 10 Feb 2026 17:09:17 +0200 Subject: [PATCH 08/12] use incremental suggestions --- src/CucumberLanguageServer.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index d2852170..8bb2b729 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -106,7 +106,7 @@ export class CucumberLanguageServer { private files: Files private gherkinSourcesCacheMap: SourceCache<'gherkin'> = new Map() private glueSourcesCacheMap: SourceCache = new Map() - private suggestionsCache: readonly Suggestion[] = [] + private suggestionsCache: Map = new Map() private expressionBuilderResultCache: ExpressionBuilderResult | undefined = undefined public registry: CucumberExpressions.ParameterTypeRegistry public expressions: readonly CucumberExpressions.Expression[] = [] @@ -447,6 +447,12 @@ export class CucumberLanguageServer { ) } + private getNewExpressionsFromLinks(): readonly CucumberExpressions.Expression[] { + return Array.from(this.expressionBuilderResult?.newExpressionLinks.values() || []).flatMap( + (links) => links.map((l) => l.expression) + ) + } + private async reindex(document?: TextDocument, settings?: Settings) { if (!settings) { settings = await this.getSettings() @@ -522,7 +528,7 @@ export class CucumberLanguageServer { } this.expressionBuilderResult = this.expressionBuilderResultCache this.connection.console.info( - `* Found ${this.expressionBuilderResult.parameterTypeLinks.size} parameter types in those glue files` + `* Found ${Array.from(this.expressionBuilderResult.parameterTypeLinks.values()).flat().length} parameter types in those glue files` ) // for (const parameterTypeLink of this.expressionBuilderResult.parameterTypeLinks) { // this.connection.console.info( @@ -530,7 +536,7 @@ export class CucumberLanguageServer { // ) // } this.connection.console.info( - `* Found ${this.expressionBuilderResult.expressionLinks.size} step definitions in those glue files` + `* Found ${Array.from(this.expressionBuilderResult.expressionLinks.values()).flat().length} step definitions in those glue files` ) for (const error of this.expressionBuilderResult.errors) { this.connection.console.error(`* Step Definition errors: ${error.stack}`) @@ -550,15 +556,25 @@ export class CucumberLanguageServer { try { const expressions = this.getExpressionsFromLinks() - this.connection.console.info(`Building suggestions from scratch`) - if (settings.buildSuggestions) { - this.suggestionsCache = buildSuggestions( + if (newGlueSource && this.expressionBuilderResult.newExpressionLinks.size > 0) { + this.connection.console.info(`Building suggestions from new expressions`) + buildSuggestions( + this.expressionBuilderResult.registry, + stepTexts, + this.getNewExpressionsFromLinks(), + this.suggestionsCache, + false, + ) + } else if (this.suggestionsCache.size === 0) { + this.connection.console.info(`Building suggestions from scratch`) + buildSuggestions( this.expressionBuilderResult.registry, stepTexts, - expressions + expressions, + this.suggestionsCache ) } - const suggestions = this.suggestionsCache + const suggestions = Array.from(this.suggestionsCache.values()) this.connection.console.info(`* Built ${suggestions.length} suggestions for auto complete`) this.searchIndex = jsSearchIndex(suggestions) const registry = this.expressionBuilderResult.registry From 92ed65d6f56430c1f30d8defa5ead7ed46d0090c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Mon, 23 Feb 2026 16:50:28 +0200 Subject: [PATCH 09/12] remove digest computation --- src/CucumberLanguageServer.ts | 8 +++----- src/fs.ts | 26 +++----------------------- src/types.ts | 1 - test/CucumberLanguageServer.test.ts | 1 - test/standalone.test.ts | 1 - 5 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 8bb2b729..12de1981 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -94,7 +94,6 @@ const defaultSettings: Settings = { parameterTypes: [], snippetTemplates: {}, forceReindex: false, - buildSuggestions: true, } export class CucumberLanguageServer { @@ -410,8 +409,7 @@ export class CucumberLanguageServer { glue: getArray(settings?.glue, defaultSettings.glue), parameterTypes: getArray(settings?.parameterTypes, defaultSettings.parameterTypes), snippetTemplates: settings?.snippetTemplates || {}, - forceReindex: settings?.forceReindex || false, - buildSuggestions: settings?.buildSuggestions || false, + forceReindex: settings?.forceReindex || false } } else { this.connection.console.error( @@ -428,11 +426,11 @@ export class CucumberLanguageServer { } private async getCachedGherkinSources(): Promise[]> { - return Array.from(this.gherkinSourcesCacheMap.values()).map((entry) => entry.source) + return Array.from(this.gherkinSourcesCacheMap.values()) } private async getCachedGlueSources(): Promise[]> { - return Array.from(this.glueSourcesCacheMap.values()).map((entry) => entry.source) + return Array.from(this.glueSourcesCacheMap.values()) } private getExpressionsFromLinks(): readonly CucumberExpressions.Expression[] { diff --git a/src/fs.ts b/src/fs.ts index a5e23ba8..b8e15d16 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,5 +1,4 @@ import { LanguageName, Source } from '@cucumber/language-service' -import { createHash } from 'crypto' import { extname, Files } from './Files.js' @@ -50,16 +49,7 @@ export async function loadGherkinSources( type LanguageNameByExt = Record -export interface SourceCacheEntry { - source: Source - digest: string -} - -export type SourceCache = Map> - -function computeDigest(content: string): string { - return createHash('sha256').update(content).digest('hex') -} +export type SourceCache = Map> export async function findUris(files: Files, globs: readonly string[]): Promise { // Run all the globs in parallel @@ -85,14 +75,6 @@ async function updateSourceInternal( languageName: L ): Promise> { const content = await files.readFile(document.uri) - const digest = computeDigest(content) - - if (sourcesCacheMap.has(document.uri)) { - const cached = sourcesCacheMap.get(document.uri) - if (cached && cached.digest === digest) { - return cached.source - } - } const source: Source = { languageName, @@ -100,7 +82,7 @@ async function updateSourceInternal( content, } - sourcesCacheMap.set(document.uri, { source, digest }) + sourcesCacheMap.set(document.uri, source) return source } @@ -149,9 +131,7 @@ async function loadSources( uri, } - // Compute digest and store in cache - const digest = computeDigest(content) - cache.set(uri, { source, digest }) + cache.set(uri, source) resolve(source) }) diff --git a/src/types.ts b/src/types.ts index 226cb502..e490c64b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,5 +11,4 @@ export type Settings = { parameterTypes: readonly ParameterTypeMeta[] snippetTemplates: Readonly>> forceReindex: boolean - buildSuggestions: boolean } diff --git a/test/CucumberLanguageServer.test.ts b/test/CucumberLanguageServer.test.ts index d6c6f1a3..b7d61603 100644 --- a/test/CucumberLanguageServer.test.ts +++ b/test/CucumberLanguageServer.test.ts @@ -131,7 +131,6 @@ describe('CucumberLanguageServer', () => { parameterTypes: [], snippetTemplates: {}, forceReindex: true, - buildSuggestions: true, } const configParams: DidChangeConfigurationParams = { settings, diff --git a/test/standalone.test.ts b/test/standalone.test.ts index 5742079c..e4637cc3 100644 --- a/test/standalone.test.ts +++ b/test/standalone.test.ts @@ -103,7 +103,6 @@ describe('Standalone', () => { parameterTypes: [], snippetTemplates: {}, forceReindex: true, - buildSuggestions: true, } const configParams: DidChangeConfigurationParams = { settings, From c1e59345ddbbfb065b8445f650f2bb8d0957bf9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Tue, 24 Feb 2026 10:36:06 +0200 Subject: [PATCH 10/12] remove forceReindex setting --- src/CucumberLanguageServer.ts | 2 -- src/types.ts | 1 - test/CucumberLanguageServer.test.ts | 1 - test/standalone.test.ts | 1 - 4 files changed, 5 deletions(-) diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 12de1981..582a1105 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -93,7 +93,6 @@ const defaultSettings: Settings = { ], parameterTypes: [], snippetTemplates: {}, - forceReindex: false, } export class CucumberLanguageServer { @@ -409,7 +408,6 @@ export class CucumberLanguageServer { glue: getArray(settings?.glue, defaultSettings.glue), parameterTypes: getArray(settings?.parameterTypes, defaultSettings.parameterTypes), snippetTemplates: settings?.snippetTemplates || {}, - forceReindex: settings?.forceReindex || false } } else { this.connection.console.error( diff --git a/src/types.ts b/src/types.ts index e490c64b..ac5b44a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,5 +10,4 @@ export type Settings = { glue: readonly string[] parameterTypes: readonly ParameterTypeMeta[] snippetTemplates: Readonly>> - forceReindex: boolean } diff --git a/test/CucumberLanguageServer.test.ts b/test/CucumberLanguageServer.test.ts index b7d61603..1ebe0307 100644 --- a/test/CucumberLanguageServer.test.ts +++ b/test/CucumberLanguageServer.test.ts @@ -130,7 +130,6 @@ describe('CucumberLanguageServer', () => { glue: [`testdata/**/*.${fileExtension}`], parameterTypes: [], snippetTemplates: {}, - forceReindex: true, } const configParams: DidChangeConfigurationParams = { settings, diff --git a/test/standalone.test.ts b/test/standalone.test.ts index e4637cc3..2475b9a1 100644 --- a/test/standalone.test.ts +++ b/test/standalone.test.ts @@ -102,7 +102,6 @@ describe('Standalone', () => { glue: ['testdata/**/*.js'], parameterTypes: [], snippetTemplates: {}, - forceReindex: true, } const configParams: DidChangeConfigurationParams = { settings, From d5dc29dd55f6db32e56130977c1776fb64613ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Tue, 24 Feb 2026 11:13:53 +0200 Subject: [PATCH 11/12] add progress notificaiton --- src/CucumberLanguageServer.ts | 46 ++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 582a1105..04483b3e 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -26,9 +26,14 @@ import { Connection, DidChangeConfigurationNotification, LocationLink, + ProgressToken, ServerCapabilities, TextDocuments, TextDocumentSyncKind, + WorkDoneProgress, + WorkDoneProgressBegin, + WorkDoneProgressEnd, + WorkDoneProgressReport, } from 'vscode-languageserver' import { TextDocument } from 'vscode-languageserver-textdocument' @@ -453,12 +458,12 @@ export class CucumberLanguageServer { if (!settings) { settings = await this.getSettings() } - // TODO: Send WorkDoneProgressBegin notification - // https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#workDoneProgress this.connection.console.info(`Reindexing ${this.rootUri}`) this.connection.console.info(`Settings: ${JSON.stringify(settings, null, 2)}`) + const token: ProgressToken = `cucumber-index-${Date.now()}` + if (document) { if (document.uri.match(/\.feature$/)) { this.connection.console.info(`Updating gherkin source ${document.uri}`) @@ -470,6 +475,15 @@ export class CucumberLanguageServer { } } else { this.connection.console.info(`Loading gherkin sources from scratch`) + await this.connection.sendRequest('window/workDoneProgress/create', { token }) + + const begin: WorkDoneProgressBegin = { + kind: 'begin', + title: 'Loading Gherkin Sources', + percentage: 10, + } + this.connection.sendProgress(WorkDoneProgress.type, token, begin) + await loadGherkinSources(this.files, settings.features, this.gherkinSourcesCacheMap) } @@ -501,6 +515,13 @@ export class CucumberLanguageServer { } } else { this.connection.console.info(`Loading glue sources from scratch`) + const report: WorkDoneProgressReport = { + kind: 'report', + percentage: 25, + message: 'Loading glue sources', + } + this.connection.sendProgress(WorkDoneProgress.type, token, report) + await loadGlueSources(this.files, settings.glue, this.glueSourcesCacheMap) } const glueSources = await this.getCachedGlueSources() @@ -510,6 +531,13 @@ export class CucumberLanguageServer { if (this.expressionBuilderResultCache === undefined) { this.connection.console.info(`Building expression builder result from scratch`) + const report: WorkDoneProgressReport = { + kind: 'report', + percentage: 50, + message: `Building expressions from ${glueSources.length} glue files`, + } + this.connection.sendProgress(WorkDoneProgress.type, token, report) + this.expressionBuilderResultCache = this.expressionBuilder.build( glueSources, settings.parameterTypes @@ -522,6 +550,7 @@ export class CucumberLanguageServer { [newGlueSource] ) } + this.expressionBuilderResult = this.expressionBuilderResultCache this.connection.console.info( `* Found ${Array.from(this.expressionBuilderResult.parameterTypeLinks.values()).flat().length} parameter types in those glue files` @@ -552,6 +581,7 @@ export class CucumberLanguageServer { try { const expressions = this.getExpressionsFromLinks() + if (newGlueSource && this.expressionBuilderResult.newExpressionLinks.size > 0) { this.connection.console.info(`Building suggestions from new expressions`) buildSuggestions( @@ -563,6 +593,13 @@ export class CucumberLanguageServer { ) } else if (this.suggestionsCache.size === 0) { this.connection.console.info(`Building suggestions from scratch`) + const report: WorkDoneProgressReport = { + kind: 'report', + percentage: 75, + message: `Building ${stepTexts.size} suggestions`, + } + this.connection.sendProgress(WorkDoneProgress.type, token, report) + buildSuggestions( this.expressionBuilderResult.registry, stepTexts, @@ -585,7 +622,10 @@ export class CucumberLanguageServer { ) } - // TODO: Send WorkDoneProgressEnd notification + const end: WorkDoneProgressEnd = { + kind: 'end', + } + this.connection.sendProgress(WorkDoneProgress.type, token, end) } } From 8071ea7f3806d2b8dc85f1e4ab91cc225f9233f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C4=81rlis=20Sergijevskis?= Date: Wed, 25 Feb 2026 13:14:36 +0200 Subject: [PATCH 12/12] use parameterTypelinks and address comments --- .eslintrc.json | 1 + src/CucumberLanguageServer.ts | 58 +++++++++++++++++------------ src/fs.ts | 4 +- test/CucumberLanguageServer.test.ts | 1 + test/standalone.test.ts | 1 + 5 files changed, 38 insertions(+), 27 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 8b78ad93..c5dc6b08 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,5 @@ { + "root": true, "env": { "browser": true, "node": true diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 04483b3e..b59c1f27 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -3,6 +3,7 @@ import { CucumberExpressions, ExpressionBuilder, ExpressionBuilderResult, + expressionLinks, getGenerateSnippetCodeAction, getGherkinCompletionItems, getGherkinDiagnostics, @@ -13,8 +14,10 @@ import { Index, jsSearchIndex, LanguageName, + parameterTypeLinks, ParserAdapter, semanticTokenTypes, + sortSuggestions, Source, Suggestion, } from '@cucumber/language-service' @@ -110,7 +113,6 @@ export class CucumberLanguageServer { private gherkinSourcesCacheMap: SourceCache<'gherkin'> = new Map() private glueSourcesCacheMap: SourceCache = new Map() private suggestionsCache: Map = new Map() - private expressionBuilderResultCache: ExpressionBuilderResult | undefined = undefined public registry: CucumberExpressions.ParameterTypeRegistry public expressions: readonly CucumberExpressions.Expression[] = [] public suggestions: readonly Suggestion[] = [] @@ -312,6 +314,12 @@ export class CucumberLanguageServer { connection.onRequest('cucumber/forceReindex', async () => { connection.console.info('Received cucumber/forceReindex request') try { + // Clear all caches for a fresh start + this.gherkinSourcesCacheMap.clear() + this.glueSourcesCacheMap.clear() + this.suggestionsCache.clear() + this.expressionBuilderResult = undefined + await this.reindex(undefined, undefined) return { success: true } } catch (err) { @@ -387,7 +395,7 @@ export class CucumberLanguageServer { private scheduleReindexing(document: TextDocument) { clearTimeout(this.reindexingTimeout) - const timeoutMillis = 1000 + const timeoutMillis = 1000 // reducing timeout as we will use incremental caching this.connection.console.info(`Scheduling reindexing in ${timeoutMillis} ms`) this.reindexingTimeout = setTimeout(() => { this.reindex(document).catch((err) => @@ -467,7 +475,7 @@ export class CucumberLanguageServer { if (document) { if (document.uri.match(/\.feature$/)) { this.connection.console.info(`Updating gherkin source ${document.uri}`) - updateGherkinSource( + await updateGherkinSource( { uri: document.uri, content: document.getText() }, this.files, this.gherkinSourcesCacheMap @@ -479,11 +487,11 @@ export class CucumberLanguageServer { const begin: WorkDoneProgressBegin = { kind: 'begin', - title: 'Loading Gherkin Sources', + title: 'Cucumber: Syncing', percentage: 10, } this.connection.sendProgress(WorkDoneProgress.type, token, begin) - + await loadGherkinSources(this.files, settings.features, this.gherkinSourcesCacheMap) } @@ -529,7 +537,7 @@ export class CucumberLanguageServer { `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` ) - if (this.expressionBuilderResultCache === undefined) { + if (this.expressionBuilderResult === undefined) { this.connection.console.info(`Building expression builder result from scratch`) const report: WorkDoneProgressReport = { kind: 'report', @@ -538,30 +546,30 @@ export class CucumberLanguageServer { } this.connection.sendProgress(WorkDoneProgress.type, token, report) - this.expressionBuilderResultCache = this.expressionBuilder.build( + this.expressionBuilderResult = this.expressionBuilder.build( glueSources, settings.parameterTypes ) } if (newGlueSource) { this.connection.console.info(`Adding new glue source to expression builder result`) - this.expressionBuilderResultCache = this.expressionBuilder.rebuild( - this.expressionBuilderResultCache, - [newGlueSource] - ) + this.expressionBuilderResult = this.expressionBuilder.rebuild(this.expressionBuilderResult, [ + newGlueSource, + ]) } - this.expressionBuilderResult = this.expressionBuilderResultCache this.connection.console.info( - `* Found ${Array.from(this.expressionBuilderResult.parameterTypeLinks.values()).flat().length} parameter types in those glue files` + `* Found ${expressionLinks(this.expressionBuilderResult).length} parameter types in those glue files` ) - // for (const parameterTypeLink of this.expressionBuilderResult.parameterTypeLinks) { - // this.connection.console.info( - // ` * {${parameterTypeLink.parameterType.name}} = ${parameterTypeLink.parameterType.regexpStrings}` - // ) - // } + if (!document) { + for (const parameterTypeLink of parameterTypeLinks(this.expressionBuilderResult)) { + this.connection.console.info( + ` * {${parameterTypeLink.parameterType.name}} = ${parameterTypeLink.parameterType.regexpStrings}` + ) + } + } this.connection.console.info( - `* Found ${Array.from(this.expressionBuilderResult.expressionLinks.values()).flat().length} step definitions in those glue files` + `* Found ${expressionLinks(this.expressionBuilderResult).length} step definitions in those glue files` ) for (const error of this.expressionBuilderResult.errors) { this.connection.console.error(`* Step Definition errors: ${error.stack}`) @@ -589,7 +597,7 @@ export class CucumberLanguageServer { stepTexts, this.getNewExpressionsFromLinks(), this.suggestionsCache, - false, + false // dont add to unmatched step texts ) } else if (this.suggestionsCache.size === 0) { this.connection.console.info(`Building suggestions from scratch`) @@ -607,7 +615,7 @@ export class CucumberLanguageServer { this.suggestionsCache ) } - const suggestions = Array.from(this.suggestionsCache.values()) + const suggestions = sortSuggestions(this.suggestionsCache) this.connection.console.info(`* Built ${suggestions.length} suggestions for auto complete`) this.searchIndex = jsSearchIndex(suggestions) const registry = this.expressionBuilderResult.registry @@ -622,10 +630,12 @@ export class CucumberLanguageServer { ) } - const end: WorkDoneProgressEnd = { - kind: 'end', + if (!document) { + const end: WorkDoneProgressEnd = { + kind: 'end', + } + this.connection.sendProgress(WorkDoneProgress.type, token, end) } - this.connection.sendProgress(WorkDoneProgress.type, token, end) } } diff --git a/src/fs.ts b/src/fs.ts index b8e15d16..d2c57789 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -74,12 +74,10 @@ async function updateSourceInternal( sourcesCacheMap: SourceCache, languageName: L ): Promise> { - const content = await files.readFile(document.uri) - const source: Source = { languageName, uri: document.uri, - content, + content: document.content, } sourcesCacheMap.set(document.uri, source) diff --git a/test/CucumberLanguageServer.test.ts b/test/CucumberLanguageServer.test.ts index 1ebe0307..28ff4413 100644 --- a/test/CucumberLanguageServer.test.ts +++ b/test/CucumberLanguageServer.test.ts @@ -90,6 +90,7 @@ describe('CucumberLanguageServer', () => { }) // Ignore log messages clientConnection.onNotification(LogMessageNotification.type, () => undefined) + clientConnection.onRequest('window/workDoneProgress/create', () => undefined) clientConnection.onUnhandledNotification((n) => { console.error('Unhandled notification', n) }) diff --git a/test/standalone.test.ts b/test/standalone.test.ts index 2475b9a1..746b8427 100644 --- a/test/standalone.test.ts +++ b/test/standalone.test.ts @@ -74,6 +74,7 @@ describe('Standalone', () => { logMessages.push(params) } }) + clientConnection.onRequest('window/workDoneProgress/create', () => undefined) clientConnection.onUnhandledNotification((n) => { console.error('Unhandled notification', n) })