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/.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/package-lock.json b/package-lock.json index 45fe3ad7..0230e2d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@cucumber/gherkin-utils": "^11.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.6.0", + "@types/micromatch": "^4.0.10", "@types/mocha": "10.0.10", "@types/node": "24.10.13", "@typescript-eslint/eslint-plugin": "7.18.0", @@ -309,6 +311,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" } @@ -490,6 +493,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" } @@ -498,6 +502,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", @@ -892,6 +897,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", @@ -903,6 +915,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", @@ -919,6 +941,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -972,6 +995,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", @@ -1155,6 +1179,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" }, @@ -2129,6 +2154,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", @@ -2199,6 +2225,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" }, @@ -4123,6 +4150,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" @@ -4730,6 +4758,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5705,17 +5734,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", @@ -6132,6 +6150,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" @@ -6768,6 +6787,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" } @@ -6917,12 +6937,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", @@ -7207,6 +7229,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", @@ -7218,6 +7246,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", @@ -7234,6 +7271,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, + "peer": true, "requires": { "undici-types": "~7.16.0" } @@ -7271,6 +7309,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", @@ -7375,7 +7414,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", @@ -8085,6 +8125,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", @@ -8140,6 +8181,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": { @@ -9880,7 +9922,8 @@ "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true + "dev": true, + "peer": true }, "prettier-linter-helpers": { "version": "1.0.1", @@ -10549,16 +10592,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", @@ -10808,7 +10841,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/package.json b/package.json index 80bda1e1..c720e441 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ }, "devDependencies": { "@cucumber/cucumber": "12.6.0", + "@types/micromatch": "^4.0.10", "@types/mocha": "10.0.10", "@types/node": "24.10.13", "@typescript-eslint/eslint-plugin": "7.18.0", @@ -96,6 +97,7 @@ "@cucumber/gherkin-utils": "^11.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 de6f0659..b59c1f27 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -3,6 +3,7 @@ import { CucumberExpressions, ExpressionBuilder, ExpressionBuilderResult, + expressionLinks, getGenerateSnippetCodeAction, getGherkinCompletionItems, getGherkinDiagnostics, @@ -12,25 +13,43 @@ import { getStepDefinitionLocationLinks, Index, jsSearchIndex, + LanguageName, + parameterTypeLinks, ParserAdapter, semanticTokenTypes, + sortSuggestions, + Source, Suggestion, } from '@cucumber/language-service' +import * as micromatch from 'micromatch' import { CodeAction, CodeActionKind, ConfigurationRequest, Connection, DidChangeConfigurationNotification, + LocationLink, + ProgressToken, ServerCapabilities, TextDocuments, TextDocumentSyncKind, + WorkDoneProgress, + WorkDoneProgressBegin, + WorkDoneProgressEnd, + WorkDoneProgressReport, } from 'vscode-languageserver' 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, + updateGherkinSource, + updateGlueSource, +} from './fs.js' import { getStepDefinitionSnippetLinks } from './getStepDefinitionSnippetLinks.js' import { Settings } from './types.js' import { version } from './version.js' @@ -91,6 +110,9 @@ export class CucumberLanguageServer { private reindexingTimeout: NodeJS.Timeout private rootUri: string private files: Files + private gherkinSourcesCacheMap: SourceCache<'gherkin'> = new Map() + private glueSourcesCacheMap: SourceCache = new Map() + private suggestionsCache: Map = new Map() public registry: CucumberExpressions.ParameterTypeRegistry public expressions: readonly CucumberExpressions.Expression[] = [] public suggestions: readonly Suggestion[] = [] @@ -136,7 +158,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}`) }) }) @@ -172,10 +194,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') @@ -212,9 +231,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.` @@ -263,7 +280,7 @@ export class CucumberLanguageServer { return getStepDefinitionLocationLinks( gherkinSource, params.position, - this.expressionBuilderResult.expressionLinks + Array.from(this.expressionBuilderResult.expressionLinks.values()).flat() ) }) } else { @@ -293,12 +310,30 @@ 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 { + // 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) { + 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 // when the text document is first opened or when its content has changed. - documents.onDidChangeContent(async (change) => { - this.scheduleReindexing() + documents.onDidSave(async (change) => { + this.scheduleReindexing(change.document) if (change.document.uri.match(/\.feature$/)) { await this.sendDiagnostics(change.document) } @@ -350,7 +385,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, @@ -358,12 +393,12 @@ export class CucumberLanguageServer { }) } - private scheduleReindexing() { + private scheduleReindexing(document: TextDocument) { clearTimeout(this.reindexingTimeout) - const timeoutMillis = 3000 + 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().catch((err) => + this.reindex(document).catch((err) => this.connection.console.error(`Failed to reindex: ${err.message}`) ) }, timeoutMillis) @@ -401,41 +436,140 @@ export class CucumberLanguageServer { } } - private async reindex(settings?: Settings) { + private async getCachedGherkinSources(): Promise[]> { + return Array.from(this.gherkinSourcesCacheMap.values()) + } + + private async getCachedGlueSources(): Promise[]> { + return Array.from(this.glueSourcesCacheMap.values()) + } + + 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 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() } - // 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(`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}`) + await updateGherkinSource( + { uri: document.uri, content: document.getText() }, + this.files, + this.gherkinSourcesCacheMap + ) + } + } else { + this.connection.console.info(`Loading gherkin sources from scratch`) + await this.connection.sendRequest('window/workDoneProgress/create', { token }) + + const begin: WorkDoneProgressBegin = { + kind: 'begin', + title: 'Cucumber: Syncing', + percentage: 10, + } + this.connection.sendProgress(WorkDoneProgress.type, token, begin) + + 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)}` ) - const stepTexts = gherkinSources.reduce( - (prev, gherkinSource) => prev.concat(buildStepTexts(gherkinSource.content)), - [] - ) - this.connection.console.info(`* Found ${stepTexts.length} steps in those feature files`) - const glueSources = await loadGlueSources(this.files, settings.glue) + 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} unique steps in those feature files`) + + 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`) + 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() this.connection.console.info( `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` ) - this.expressionBuilderResult = this.expressionBuilder.build( - glueSources, - settings.parameterTypes - ) + + if (this.expressionBuilderResult === 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.expressionBuilderResult = this.expressionBuilder.build( + glueSources, + settings.parameterTypes + ) + } + if (newGlueSource) { + this.connection.console.info(`Adding new glue source to expression builder result`) + this.expressionBuilderResult = this.expressionBuilder.rebuild(this.expressionBuilderResult, [ + newGlueSource, + ]) + } + this.connection.console.info( - `* Found ${this.expressionBuilderResult.parameterTypeLinks.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 ${this.expressionBuilderResult.expressionLinks.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}`) @@ -454,12 +588,34 @@ export class CucumberLanguageServer { this.connection.languages.semanticTokens.refresh() try { - const expressions = this.expressionBuilderResult.expressionLinks.map((l) => l.expression) - const suggestions = buildSuggestions( - this.expressionBuilderResult.registry, - stepTexts, - expressions - ) + const expressions = this.getExpressionsFromLinks() + + 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 // dont add to unmatched step texts + ) + } 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, + expressions, + this.suggestionsCache + ) + } + 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 @@ -474,7 +630,12 @@ export class CucumberLanguageServer { ) } - // TODO: Send WorkDoneProgressEnd notification + if (!document) { + const end: WorkDoneProgressEnd = { + kind: 'end', + } + this.connection.sendProgress(WorkDoneProgress.type, token, end) + } } } diff --git a/src/fs.ts b/src/fs.ts index 7254604b..d2c57789 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] @@ -27,9 +28,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 { @@ -38,13 +41,16 @@ 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 type SourceCache = Map> + export async function findUris(files: Files, globs: readonly string[]): Promise { // Run all the globs in parallel const urisPromises = globs.reduce[]>((prev, glob) => { @@ -57,29 +63,79 @@ 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 source: Source = { + languageName, + uri: document.uri, + content: document.content, + } + + sourcesCacheMap.set(document.uri, source) + 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[], 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, - }) - ) + } + + cache.set(uri, source) + + resolve(source) + }) }) ) ) + + return sources } 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) })