diff --git a/package-lock.json b/package-lock.json index 0382afc4..89c0ce4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "0.11.0", "license": "MIT", "dependencies": { - "@cucumber/cucumber-expressions": "^15.1.1", + "@cucumber/cucumber-expressions": "^15.2.0", "@cucumber/gherkin-utils": "^7.0.0", - "@cucumber/language-service": "^0.21.0", + "@cucumber/language-service": "^0.22.1", "fast-glob": "3.2.11", "source-map-support": "0.5.21", "vscode-languageserver": "8.0.1", @@ -135,9 +135,18 @@ } }, "node_modules/@cucumber/cucumber-expressions": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-15.2.0.tgz", + "integrity": "sha512-qAzz9ogcTuosFZYfueSTWnD6KxiIAAu09HwLwz1XhYL/MhfLjyq1iQN6mOnKln/hr2jX/U98C92VAlquhXDo7Q==", + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/cucumber/node_modules/@cucumber/cucumber-expressions": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-15.1.1.tgz", "integrity": "sha512-+LtEEMJpnbppkZO3VZttgPV6Dcocmwy4oF4bDARZchcRV+hqQYZi7T1jp8cDO6EDng0EMzFNGiveAOh/eAO83A==", + "dev": true, "dependencies": { "regexp-match-indices": "1.0.2" } @@ -230,17 +239,19 @@ } }, "node_modules/@cucumber/language-service": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@cucumber/language-service/-/language-service-0.21.0.tgz", - "integrity": "sha512-Sj3ibds6JzNMT5BhuIVoBWHrBypZwfubxAhTUD6hHEKdOkmUZ2N3mvGh2C360eoUoM4b1G/9jXEugbXfdYDIdw==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@cucumber/language-service/-/language-service-0.22.1.tgz", + "integrity": "sha512-m3pnjScmKrzlsZBQj0+VsvRzy/yJNgzm5psiTV39rAKJX4QC83kD9B41a78GYQB9FqbYkTCAleHHfc4obD+apQ==", "dependencies": { - "@cucumber/cucumber-expressions": "^15.1.1", + "@cucumber/cucumber-expressions": "^15.2.0", "@cucumber/gherkin": "^23.0.1", "@cucumber/gherkin-utils": "^7.0.0", "@cucumber/messages": "^18.0.0", "@types/js-search": "1.4.0", + "@types/mustache": "4.1.2", "fuse.js": "6.6.2", "js-search": "2.0.0", + "mustache": "4.2.0", "tree-sitter": "0.20.0", "tree-sitter-c-sharp": "0.19.1", "tree-sitter-java": "0.19.1", @@ -585,6 +596,11 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, + "node_modules/@types/mustache": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.1.2.tgz", + "integrity": "sha512-c4OVMMcyodKQ9dpwBwh3ofK9P6U9ZktKU9S+p33UqwMNN1vlv2P0zJZUScTshnx7OEoIIRcCFNQ904sYxZz8kg==" + }, "node_modules/@types/node": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.35.tgz", @@ -4124,6 +4140,14 @@ "node": ">=8" } }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -7036,6 +7060,15 @@ "yup": "^0.32.11" }, "dependencies": { + "@cucumber/cucumber-expressions": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-15.1.1.tgz", + "integrity": "sha512-+LtEEMJpnbppkZO3VZttgPV6Dcocmwy4oF4bDARZchcRV+hqQYZi7T1jp8cDO6EDng0EMzFNGiveAOh/eAO83A==", + "dev": true, + "requires": { + "regexp-match-indices": "1.0.2" + } + }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -7048,9 +7081,9 @@ } }, "@cucumber/cucumber-expressions": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-15.1.1.tgz", - "integrity": "sha512-+LtEEMJpnbppkZO3VZttgPV6Dcocmwy4oF4bDARZchcRV+hqQYZi7T1jp8cDO6EDng0EMzFNGiveAOh/eAO83A==", + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-15.2.0.tgz", + "integrity": "sha512-qAzz9ogcTuosFZYfueSTWnD6KxiIAAu09HwLwz1XhYL/MhfLjyq1iQN6mOnKln/hr2jX/U98C92VAlquhXDo7Q==", "requires": { "regexp-match-indices": "1.0.2" } @@ -7114,17 +7147,19 @@ "requires": {} }, "@cucumber/language-service": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@cucumber/language-service/-/language-service-0.21.0.tgz", - "integrity": "sha512-Sj3ibds6JzNMT5BhuIVoBWHrBypZwfubxAhTUD6hHEKdOkmUZ2N3mvGh2C360eoUoM4b1G/9jXEugbXfdYDIdw==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@cucumber/language-service/-/language-service-0.22.1.tgz", + "integrity": "sha512-m3pnjScmKrzlsZBQj0+VsvRzy/yJNgzm5psiTV39rAKJX4QC83kD9B41a78GYQB9FqbYkTCAleHHfc4obD+apQ==", "requires": { - "@cucumber/cucumber-expressions": "^15.1.1", + "@cucumber/cucumber-expressions": "^15.2.0", "@cucumber/gherkin": "^23.0.1", "@cucumber/gherkin-utils": "^7.0.0", "@cucumber/messages": "^18.0.0", "@types/js-search": "1.4.0", + "@types/mustache": "4.1.2", "fuse.js": "6.6.2", "js-search": "2.0.0", + "mustache": "4.2.0", "tree-sitter": "0.20.0", "tree-sitter-c-sharp": "0.19.1", "tree-sitter-java": "0.19.1", @@ -7417,6 +7452,11 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, + "@types/mustache": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.1.2.tgz", + "integrity": "sha512-c4OVMMcyodKQ9dpwBwh3ofK9P6U9ZktKU9S+p33UqwMNN1vlv2P0zJZUScTshnx7OEoIIRcCFNQ904sYxZz8kg==" + }, "@types/node": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.35.tgz", @@ -10046,6 +10086,11 @@ "minimatch": "^3.0.4" } }, + "mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==" + }, "mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", diff --git a/package.json b/package.json index 486feef2..224a7cea 100644 --- a/package.json +++ b/package.json @@ -82,9 +82,9 @@ "vscode-languageserver-protocol": "3.17.1" }, "dependencies": { - "@cucumber/cucumber-expressions": "^15.1.1", + "@cucumber/cucumber-expressions": "^15.2.0", "@cucumber/gherkin-utils": "^7.0.0", - "@cucumber/language-service": "^0.21.0", + "@cucumber/language-service": "^0.22.1", "fast-glob": "3.2.11", "source-map-support": "0.5.21", "vscode-languageserver": "8.0.1", diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index 3e42b422..bda2c4cd 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -3,6 +3,7 @@ import { buildSuggestions, ExpressionBuilder, ExpressionBuilderResult, + getGenerateSnippetCodeActions, getGherkinCompletionItems, getGherkinDiagnostics, getGherkinFormattingEdits, @@ -13,7 +14,11 @@ import { ParserAdapter, semanticTokenTypes, } from '@cucumber/language-service' +import { stat as statCb } from 'fs' +import { extname } from 'path' +import { promisify } from 'util' import { + CodeActionKind, ConfigurationRequest, Connection, DidChangeConfigurationNotification, @@ -24,9 +29,12 @@ import { import { TextDocument } from 'vscode-languageserver-textdocument' import { buildStepTexts } from './buildStepTexts.js' -import { loadGherkinSources, loadGlueSources } from './fs.js' +import { getLanguage, loadGherkinSources, loadGlueSources } from './fs.js' +import { guessStepDefinitionSnippetLink } from './guessStepDefinitionSnippetLink.js' import { Settings } from './types.js' +const stat = promisify(statCb) + type ServerInfo = { name: string } @@ -44,13 +52,20 @@ const defaultSettings: Settings = { '*specs*/**/.cs', ], parameterTypes: [], + snippetTemplates: {}, } export class CucumberLanguageServer { private readonly expressionBuilder: ExpressionBuilder private searchIndex: Index - private expressionBuilderResult: ExpressionBuilderResult = { expressionLinks: [], errors: [] } + private expressionBuilderResult: ExpressionBuilderResult = { + expressionLinks: [], + parameterTypeLinks: [], + errors: [], + registry: new ParameterTypeRegistry(), + } private reindexingTimeout: NodeJS.Timeout + private folderUri: string constructor( private readonly connection: Connection, @@ -61,8 +76,13 @@ export class CucumberLanguageServer { connection.onInitialize(async (params) => { await parserAdapter.init() + if (params.workspaceFolders && params.workspaceFolders.length > 0) { + this.folderUri = params.workspaceFolders[0].uri + } + if (params.capabilities.workspace?.configuration) { connection.onDidChangeConfiguration((params) => { + this.connection.console.info(`Client sent workspace/configuration`) this.reindex(params.settings).catch((err) => { connection.console.error(`Failed to reindex: ${err.stack}`) }) @@ -70,9 +90,7 @@ export class CucumberLanguageServer { try { await connection.client.register(DidChangeConfigurationNotification.type) } catch (err) { - connection.console.info( - `Could not register DidChangeConfigurationNotification: "${err.message}" - this is OK` - ) + connection.console.info(`Client does not support client/registerCapability. This is OK.`) } } else { this.connection.console.info('onDidChangeConfiguration is disabled') @@ -140,6 +158,49 @@ export class CucumberLanguageServer { connection.console.info('onDocumentFormatting is disabled') } + if (params.capabilities.textDocument?.codeAction) { + connection.onCodeAction(async (params) => { + const diagnostics = params.context.diagnostics + if (this.folderUri) { + const settings = await this.getSettings() + const link = await guessStepDefinitionSnippetLink( + this.expressionBuilderResult.expressionLinks.map((l) => l.locationLink) + ) + if (!link) { + connection.console.info( + `Unable to generate step definition. Please create one first manually.` + ) + return [] + } + const languageName = getLanguage(extname(link.targetUri)) + if (!languageName) { + connection.console.info( + `Unable to generate step definition snippet for unknown extension ${link}` + ) + return [] + } + const mustacheTemplate = settings.snippetTemplates[languageName] + let createFile = false + try { + await stat(new URL(link.targetUri)) + } catch { + createFile = true + } + return getGenerateSnippetCodeActions( + diagnostics, + link, + createFile, + mustacheTemplate, + languageName, + this.expressionBuilderResult.registry + ) + } + return [] + }) + } else { + connection.console.info('onCodeAction is disabled') + } + if (params.capabilities.textDocument?.definition) { connection.onDefinition((params) => { const doc = documents.get(params.textDocument.uri) @@ -191,6 +252,11 @@ export class CucumberLanguageServer { completionProvider: { resolveProvider: false, }, + codeActionProvider: { + resolveProvider: false, + workDoneProgress: false, + codeActionKinds: [CodeActionKind.QuickFix], + }, workspace: { workspaceFolders: { changeNotifications: true, @@ -238,7 +304,7 @@ export class CucumberLanguageServer { }, timeoutMillis) } - private async getSettings(): Promise { + private async getSettings(): Promise { try { const config = await this.connection.sendRequest(ConfigurationRequest.type, { items: [ @@ -254,10 +320,23 @@ export class CucumberLanguageServer { features: getArray(settings?.features, defaultSettings.features), glue: getArray(settings?.glue, defaultSettings.glue), parameterTypes: getArray(settings?.parameterTypes, defaultSettings.parameterTypes), + snippetTemplates: {}, } + } else { + this.connection.console.error( + `The client did not respons with a config we can process: ${JSON.stringify( + config, + null, + 2 + )}` + ) + this.connection.console.error(`Using default settings: ${defaultSettings}`) + return defaultSettings } } catch (err) { - this.connection.console.error('Failed to request configuration: ' + err.message) + this.connection.console.error(`Failed to request configuration: ${err.message}`) + this.connection.console.error(`Using default settings: ${defaultSettings}`) + return defaultSettings } } @@ -267,14 +346,18 @@ export class CucumberLanguageServer { this.connection.console.info(`Reindexing...`) const gherkinSources = await loadGherkinSources(settings.features) - this.connection.console.info(`* Found ${gherkinSources.length} feature file(s)`) + 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(settings.glue) - this.connection.console.info(`* Found ${glueSources.length} glue file(s)`) + this.connection.console.info( + `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` + ) this.expressionBuilderResult = this.expressionBuilder.build( glueSources, settings.parameterTypes @@ -282,6 +365,9 @@ export class CucumberLanguageServer { this.connection.console.info( `* Found ${this.expressionBuilderResult.expressionLinks.length} step definitions in those glue files` ) + this.connection.console.info( + `* Found ${this.expressionBuilderResult.parameterTypeLinks.length} parameter types in those glue files` + ) for (const error of this.expressionBuilderResult.errors) { this.connection.console.error(`* Step Definition errors: ${error.message}`) } diff --git a/src/fs.ts b/src/fs.ts index 42fea479..feda3498 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -19,30 +19,39 @@ const glueExtensions = new Set(Object.keys(glueLanguageNameByExt)) export async function loadGlueSources( globs: readonly string[] ): Promise[]> { - return loadGlobs(globs, glueExtensions, glueLanguageNameByExt) + return loadSources(globs, glueExtensions, glueLanguageNameByExt) +} + +export function getLanguage(ext: string): LanguageName | undefined { + return glueLanguageNameByExt[ext] } export async function loadGherkinSources( globs: readonly string[] ): Promise[]> { - return loadGlobs(globs, new Set(['.feature']), { '.feature': 'gherkin' }) + return loadSources(globs, new Set(['.feature']), { '.feature': 'gherkin' }) } type LanguageNameByExt = Record -async function loadGlobs( +export async function findPaths(globs: readonly string[]): Promise { + const pathPromises = globs.reduce[]>((prev, glob) => { + return prev.concat(fg(glob, { caseSensitiveMatch: false, onlyFiles: true })) + }, []) + const pathArrays = await Promise.all(pathPromises) + const paths = pathArrays.flatMap((paths) => paths) + return [...new Set(paths).values()].sort() +} + +async function loadSources( globs: readonly string[], extensions: Set, languageNameByExt: LanguageNameByExt ): Promise[]> { - const filePromises = globs.reduce[]>((prev, glob) => { - return prev.concat(fg(glob, { caseSensitiveMatch: false })) - }, []) - const fileArrays = await Promise.all(filePromises) + const paths = await findPaths(globs) return Promise.all( - fileArrays - .flatMap((paths) => paths) + paths .filter((path) => extensions.has(extname(path))) .map>>( (path) => diff --git a/src/guessStepDefinitionSnippetLink.ts b/src/guessStepDefinitionSnippetLink.ts new file mode 100644 index 00000000..3028c63c --- /dev/null +++ b/src/guessStepDefinitionSnippetLink.ts @@ -0,0 +1,27 @@ +import { LocationLink, Range } from 'vscode-languageserver-types' + +export async function guessStepDefinitionSnippetLink( + links: readonly LocationLink[] +): Promise { + if (links.length > 0) { + // TODO: Find the most relevant one by looking at the words in the expression + // If none found, use the most recently modified + const link = links[links.length - 1] + + // Insert right after the last step definition + const targetRange = Range.create( + link.targetRange.end.line + 1, + 0, + link.targetRange.end.line + 1, + 0 + ) + return { + targetUri: link.targetUri, + targetRange, + targetSelectionRange: targetRange, + } + } else { + // TODO: try to guess extension and path by looking for package.json, pom.xml etc + return undefined + } +} diff --git a/src/types.ts b/src/types.ts index 4f97f4d1..ac5b44a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { LanguageName } from '@cucumber/language-service' + export type ParameterTypeMeta = { name: string; regexp: string } /** @@ -7,4 +9,5 @@ export type Settings = { features: readonly string[] glue: readonly string[] parameterTypes: readonly ParameterTypeMeta[] + snippetTemplates: Readonly>> } diff --git a/test/CucumberLanguageServer.test.ts b/test/CucumberLanguageServer.test.ts index 88540ca0..5f807df2 100644 --- a/test/CucumberLanguageServer.test.ts +++ b/test/CucumberLanguageServer.test.ts @@ -110,6 +110,7 @@ describe('CucumberLanguageServer', () => { features: ['testdata/gherkin/*.feature'], glue: ['testdata/typescript/*.ts'], parameterTypes: [], + snippetTemplates: {}, } const configParams: DidChangeConfigurationParams = { settings, diff --git a/test/guessStepDefinitionSnippetPath.test.ts b/test/guessStepDefinitionSnippetPath.test.ts new file mode 100644 index 00000000..9576d458 --- /dev/null +++ b/test/guessStepDefinitionSnippetPath.test.ts @@ -0,0 +1,19 @@ +import assert from 'assert' +import { LocationLink, Range } from 'vscode-languageserver-types' + +import { guessStepDefinitionSnippetLink } from '../src/guessStepDefinitionSnippetLink.js' + +describe('guessStepDefinitionSnippetPath', () => { + it('creates a location 2 lines below the first link', async () => { + const targetRange = Range.create(10, 0, 20, 14) + const link: LocationLink = { + targetUri: 'file://home/testdata/typescript/*.ts', + targetRange, + targetSelectionRange: targetRange, + } + + const result = await guessStepDefinitionSnippetLink([link]) + assert.strictEqual(result!.targetUri, link.targetUri) + assert.deepStrictEqual(result!.targetRange, Range.create(21, 0, 21, 0)) + }) +})