diff --git a/node/package.json b/node/package.json index 6371344c..132eecad 100644 --- a/node/package.json +++ b/node/package.json @@ -1,7 +1,7 @@ { "name": "wasm", "description": "Cucumber Language server using tree-sitter node bindings", - "main": "../dist/cjs/src/node/startNodeServer.js", - "module": "../dist/esm/src/node/startNodeServer.js", - "types": "../dist/esm/src/node/startNodeServer.d.ts" + "main": "../dist/cjs/src/node/index.js", + "module": "../dist/esm/src/node/index.js", + "types": "../dist/esm/src/node/index.d.ts" } diff --git a/package.json b/package.json index 136285db..8075e14b 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,21 @@ "version": "0.12.14", "description": "Cucumber Language Server", "type": "module", - "main": "dist/cjs/src/node/startNodeServer.js", - "module": "dist/esm/src/node/startNodeServer.js", - "types": "dist/esm/src/node/startNodeServer.d.ts", + "main": "dist/cjs/src/index.js", + "module": "dist/esm/src/index.js", + "types": "dist/esm/src/index.d.ts", "exports": { ".": { - "import": "./dist/esm/src/node/startNodeServer.js", - "require": "./dist/cjs/src/node/startNodeServer.js" + "import": "./dist/esm/src/index.js", + "require": "./dist/cjs/src/index.js" + }, + "./node": { + "import": "./dist/esm/src/node/index.js", + "require": "./dist/cjs/src/node/index.js" }, "./wasm": { - "import": "./dist/esm/src/wasm/startWasmServer.js", - "require": "./dist/cjs/src/wasm/startWasmServer.js" + "import": "./dist/esm/src/wasm/index.js", + "require": "./dist/cjs/src/wasm/index.js" } }, "files": [ diff --git a/src/CucumberLanguageServer.ts b/src/CucumberLanguageServer.ts index bad8f2fd..6f527175 100644 --- a/src/CucumberLanguageServer.ts +++ b/src/CucumberLanguageServer.ts @@ -12,10 +12,8 @@ import { jsSearchIndex, ParserAdapter, semanticTokenTypes, + Suggestion, } from '@cucumber/language-service' -import { stat as statCb } from 'fs' -import { extname, relative } from 'path' -import { promisify } from 'util' import { CodeAction, CodeActionKind, @@ -29,13 +27,12 @@ import { import { TextDocument } from 'vscode-languageserver-textdocument' import { buildStepTexts } from './buildStepTexts.js' +import { extname, Files } from './Files' import { getLanguage, loadGherkinSources, loadGlueSources } from './fs.js' import { getStepDefinitionSnippetLinks } from './getStepDefinitionSnippetLinks.js' import { Settings } from './types.js' import { version } from './version.js' -const stat = promisify(statCb) - type ServerInfo = { name: string version: string @@ -84,14 +81,23 @@ export class CucumberLanguageServer { private expressionBuilderResult: ExpressionBuilderResult | undefined = undefined private reindexingTimeout: NodeJS.Timeout private rootPath: string + #suggestions: readonly Suggestion[] + #files: Files + + get suggestions() { + return this.#suggestions + } constructor( private readonly connection: Connection, private readonly documents: TextDocuments, - parserAdapter: ParserAdapter + parserAdapter: ParserAdapter, + private readonly makeFiles: (rootUri: string) => Files ) { this.expressionBuilder = new ExpressionBuilder(parserAdapter) + connection.onInitialize(async (params) => { + // connection.console.log(`PARAMS: ${JSON.stringify(params, null, 2)}`) await parserAdapter.init() if (params.clientInfo) { connection.console.info( @@ -103,11 +109,14 @@ export class CucumberLanguageServer { if (params.rootPath) { this.rootPath = params.rootPath + } else if (params.rootUri) { + this.rootPath = new URL(params.rootUri).pathname } else if (params.workspaceFolders && params.workspaceFolders.length > 0) { this.rootPath = new URL(params.workspaceFolders[0].uri).pathname } else { - connection.console.error(`Client did not send rootPath or workspaceFolders`) + connection.console.error(`Could not determine rootPath`) } + this.#files = makeFiles(this.rootPath) // Some users have reported that the globs don't find any files. This is to debug that issue connection.console.info(`Root path : ${this.rootPath}`) connection.console.info(`Current dir : ${process.cwd()}`) @@ -211,13 +220,8 @@ export class CucumberLanguageServer { return [] } const mustacheTemplate = settings.snippetTemplates[languageName] - let createFile = false - try { - await stat(new URL(link.targetUri)) - } catch { - createFile = true - } - const relativePath = relative(this.rootPath, new URL(link.targetUri).pathname) + const createFile = !(await this.#files.exists(link.targetUri)) + const relativePath = this.#files.relative(link.targetUri) const codeAction = getGenerateSnippetCodeAction( diagnostics, link, @@ -377,8 +381,8 @@ export class CucumberLanguageServer { // TODO: Send WorkDoneProgressBegin notification // https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#workDoneProgress - this.connection.console.info(`Reindexing...`) - const gherkinSources = await loadGherkinSources(settings.features) + this.connection.console.info(`Reindexing ${this.rootPath}`) + const gherkinSources = await loadGherkinSources(this.#files, settings.features) this.connection.console.info( `* Found ${gherkinSources.length} feature file(s) in ${JSON.stringify(settings.features)}` ) @@ -387,7 +391,7 @@ export class CucumberLanguageServer { [] ) this.connection.console.info(`* Found ${stepTexts.length} steps in those feature files`) - const glueSources = await loadGlueSources(settings.glue) + const glueSources = await loadGlueSources(this.#files, settings.glue) this.connection.console.info( `* Found ${glueSources.length} glue file(s) in ${JSON.stringify(settings.glue)}` ) @@ -423,13 +427,15 @@ export class CucumberLanguageServer { this.connection.languages.semanticTokens.refresh() try { - const suggestions = buildSuggestions( + this.#suggestions = buildSuggestions( this.expressionBuilderResult.registry, stepTexts, this.expressionBuilderResult.expressionLinks.map((l) => l.expression) ) - this.connection.console.info(`* Built ${suggestions.length} suggestions for auto complete`) - this.searchIndex = jsSearchIndex(suggestions) + this.connection.console.info( + `* Built ${this.#suggestions.length} suggestions for auto complete` + ) + this.searchIndex = jsSearchIndex(this.#suggestions) } catch (err) { this.connection.console.error(err.stack) this.connection.console.error( diff --git a/src/Files.ts b/src/Files.ts new file mode 100644 index 00000000..cb22384e --- /dev/null +++ b/src/Files.ts @@ -0,0 +1,13 @@ +export interface Files { + exists(uri: string): Promise + readFile(path: string): Promise + findFiles(glob: string): Promise + join(...paths: string[]): string + relative(uri: string): string + toUri(path: string): string +} + +export function extname(path: string): string { + // Roughly-enough implements https://nodejs.org/dist/latest-v18.x/docs/api/path.html#pathextnamepath + return path.substring(path.lastIndexOf('.'), path.length) || '' +} diff --git a/src/fs.ts b/src/fs.ts index d5240bdf..f3be24a9 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,8 +1,6 @@ import { LanguageName, Source } from '@cucumber/language-service' -import fg from 'fast-glob' -import fs from 'fs/promises' -import { extname, resolve as resolvePath } from 'path' -import url from 'url' + +import { extname, Files } from './Files' export const glueExtByLanguageName: Record = { tsx: ['.ts', '.tsx'], @@ -25,9 +23,10 @@ const glueLanguageNameByExt = Object.fromEntries(entries) const glueExtensions = new Set(Object.keys(glueLanguageNameByExt)) export async function loadGlueSources( + files: Files, globs: readonly string[] ): Promise[]> { - return loadSources(globs, glueExtensions, glueLanguageNameByExt) + return loadSources(files, globs, glueExtensions, glueLanguageNameByExt) } export function getLanguage(ext: string): LanguageName | undefined { @@ -35,28 +34,36 @@ export function getLanguage(ext: string): LanguageName | undefined { } export async function loadGherkinSources( + files: Files, globs: readonly string[] ): Promise[]> { - return loadSources(globs, new Set(['.feature']), { '.feature': 'gherkin' }) + return loadSources(files, globs, new Set(['.feature']), { '.feature': 'gherkin' }) } type LanguageNameByExt = Record -export async function findPaths(globs: readonly string[]): Promise { - const pathPromises = globs.reduce[]>((prev, glob) => { - return prev.concat(fg(glob, { caseSensitiveMatch: false, onlyFiles: true })) +export async function findPaths( + files: Files, + globs: readonly string[] +): Promise { + // Run all the globs in parallel + const pathsPromises = globs.reduce[]>((prev, glob) => { + const pathsPromise = files.findFiles(glob) + return prev.concat(pathsPromise) }, []) - const pathArrays = await Promise.all(pathPromises) + const pathArrays = await Promise.all(pathsPromises) + // Flatten them all const paths = pathArrays.flatMap((paths) => paths) return [...new Set(paths).values()].sort() } async function loadSources( + files: Files, globs: readonly string[], extensions: Set, languageNameByExt: LanguageNameByExt ): Promise[]> { - const paths = await findPaths(globs) + const paths = await findPaths(files, globs) return Promise.all( paths @@ -65,11 +72,11 @@ async function loadSources( (path) => new Promise>((resolve) => { const languageName = languageNameByExt[extname(path)] - return fs.readFile(path, 'utf-8').then((content) => + return files.readFile(path).then((content) => resolve({ languageName, content, - uri: url.pathToFileURL(resolvePath(path)).href, + uri: files.toUri(path), }) ) }) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..7a204f42 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from './Files.js' +export * from './newWasmServer.js' +export * from './startServer.js' diff --git a/src/newWasmServer.ts b/src/newWasmServer.ts new file mode 100644 index 00000000..fd68bf2b --- /dev/null +++ b/src/newWasmServer.ts @@ -0,0 +1,30 @@ +import { ParserAdapter } from '@cucumber/language-service' +import { WasmParserAdapter } from '@cucumber/language-service/wasm' +import { PassThrough } from 'stream' +import { TextDocuments } from 'vscode-languageserver' +import { createConnection } from 'vscode-languageserver/node' +import { TextDocument } from 'vscode-languageserver-textdocument' + +import { CucumberLanguageServer } from './CucumberLanguageServer.js' +import { Files } from './Files' + +export type StreamInfo = { + writer: NodeJS.WritableStream + reader: NodeJS.ReadableStream +} + +export function newWasmServer(wasmBaseUrl: string, makeFiles: (rootUri: string) => Files) { + const adapter: ParserAdapter = new WasmParserAdapter(wasmBaseUrl) + const inputStream = new PassThrough() + const outputStream = new PassThrough() + + const connection = createConnection(inputStream, outputStream) + const documents = new TextDocuments(TextDocument) + new CucumberLanguageServer(connection, documents, adapter, makeFiles) + connection.listen() + + return { + writer: inputStream, + reader: outputStream, + } +} diff --git a/src/node/NodeFiles.ts b/src/node/NodeFiles.ts new file mode 100644 index 00000000..ef2e650c --- /dev/null +++ b/src/node/NodeFiles.ts @@ -0,0 +1,39 @@ +import fg from 'fast-glob' +import fs from 'fs/promises' +import path, { relative, resolve as resolvePath } from 'path' +import url from 'url' + +import { Files } from '../Files' + +export class NodeFiles implements Files { + constructor(private readonly rootUri: string) {} + + async exists(uri: string): Promise { + try { + await fs.stat(new URL(uri)) + return true + } catch { + return false + } + } + + readFile(path: string): Promise { + return fs.readFile(path, 'utf-8') + } + + findFiles(glob: string): Promise { + return fg(glob, { cwd: this.rootUri, caseSensitiveMatch: false, onlyFiles: true }) + } + + join(...paths: string[]): string { + return path.join(this.rootUri, ...paths) + } + + relative(uri: string): string { + return relative(this.rootUri, new URL(uri).pathname) + } + + toUri(path: string): string { + return url.pathToFileURL(resolvePath(path)).href + } +} diff --git a/src/node/index.ts b/src/node/index.ts new file mode 100644 index 00000000..9eab3403 --- /dev/null +++ b/src/node/index.ts @@ -0,0 +1,2 @@ +export * from './NodeFiles.js' +export * from './startNodeServer.js' diff --git a/src/node/startNodeServer.ts b/src/node/startNodeServer.ts index 7f23f3c9..451b3474 100644 --- a/src/node/startNodeServer.ts +++ b/src/node/startNodeServer.ts @@ -1,7 +1,8 @@ import { NodeParserAdapter } from '@cucumber/language-service/node' -import { startServer } from '../startServer' +import { startServer } from '../startServer.js' +import { NodeFiles } from './NodeFiles' export function startNodeServer() { - startServer(new NodeParserAdapter()) + startServer(new NodeParserAdapter(), (rootUri) => new NodeFiles(rootUri)) } diff --git a/src/startServer.ts b/src/startServer.ts index 4b4d4e15..cc2fe741 100644 --- a/src/startServer.ts +++ b/src/startServer.ts @@ -4,12 +4,13 @@ import { createConnection, ProposedFeatures } from 'vscode-languageserver/node' import { TextDocument } from 'vscode-languageserver-textdocument' import { CucumberLanguageServer } from './CucumberLanguageServer.js' +import { Files } from './Files' import { version } from './version.js' -export function startServer(adapter: ParserAdapter) { +export function startServer(adapter: ParserAdapter, makeFiles: (rootUri: string) => Files) { const connection = createConnection(ProposedFeatures.all) const documents = new TextDocuments(TextDocument) - new CucumberLanguageServer(connection, documents, adapter) + new CucumberLanguageServer(connection, documents, adapter, makeFiles) connection.listen() // Don't die on unhandled Promise rejections diff --git a/src/wasm/index.ts b/src/wasm/index.ts new file mode 100644 index 00000000..ce2a0b39 --- /dev/null +++ b/src/wasm/index.ts @@ -0,0 +1 @@ +export * from './startWasmServer.js' diff --git a/src/wasm/startWasmServer.ts b/src/wasm/startWasmServer.ts index 6c3097d5..d540d525 100644 --- a/src/wasm/startWasmServer.ts +++ b/src/wasm/startWasmServer.ts @@ -1,7 +1,8 @@ import { WasmParserAdapter } from '@cucumber/language-service/wasm' -import { startServer } from '../startServer' +import { Files } from '../Files' +import { startServer } from '../startServer.js' -export function startWasmServer(wasmBaseUrl: string) { - startServer(new WasmParserAdapter(wasmBaseUrl)) +export function startWasmServer(wasmBaseUrl: string, makeFiles: (rootUri: string) => Files) { + startServer(new WasmParserAdapter(wasmBaseUrl), makeFiles) } diff --git a/test/CucumberLanguageServer.test.ts b/test/CucumberLanguageServer.test.ts index 9b9a4aea..597e9d95 100644 --- a/test/CucumberLanguageServer.test.ts +++ b/test/CucumberLanguageServer.test.ts @@ -23,6 +23,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument' import { CompletionItem, CompletionItemKind } from 'vscode-languageserver-types' import { CucumberLanguageServer } from '../src/CucumberLanguageServer.js' +import { NodeFiles } from '../src/node/NodeFiles' import { Settings } from '../src/types' describe('CucumberLanguageServer', () => { @@ -35,28 +36,14 @@ describe('CucumberLanguageServer', () => { beforeEach(async () => { inputStream = new TestStream() outputStream = new TestStream() - const logger = new NullLogger() - clientConnection = createProtocolConnection( - new StreamMessageReader(outputStream), - new StreamMessageWriter(inputStream), - logger - ) - clientConnection.onError((err) => { - console.error('ERROR', err) - }) - // Ignore log messages - clientConnection.onNotification(LogMessageNotification.type, () => undefined) - clientConnection.onUnhandledNotification((n) => { - console.error('Unhandled notification', n) - }) - clientConnection.listen() serverConnection = createConnection(inputStream, outputStream) documents = new TextDocuments(TextDocument) new CucumberLanguageServer( serverConnection, documents, - new WasmParserAdapter('node_modules/@cucumber/language-service/dist') + new WasmParserAdapter('node_modules/@cucumber/language-service/dist'), + (rootUri) => new NodeFiles(rootUri) ) serverConnection.listen() @@ -92,6 +79,21 @@ describe('CucumberLanguageServer', () => { }, workspaceFolders: null, } + const logger = new NullLogger() + clientConnection = createProtocolConnection( + new StreamMessageReader(outputStream), + new StreamMessageWriter(inputStream), + logger + ) + clientConnection.onError((err) => { + console.error('ERROR', err) + }) + // Ignore log messages + clientConnection.onNotification(LogMessageNotification.type, () => undefined) + clientConnection.onUnhandledNotification((n) => { + console.error('Unhandled notification', n) + }) + clientConnection.listen() const { serverInfo } = await clientConnection.sendRequest( InitializeRequest.type, initializeParams diff --git a/wasm/package.json b/wasm/package.json index d81c77ce..248c3920 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -1,7 +1,7 @@ { "name": "wasm", "description": "Cucumber Language server using tree-sitter wasm bindings", - "main": "../dist/cjs/src/wasm/startWasmServer.js", - "module": "../dist/esm/src/wasm/startWasmServer.js", - "types": "../dist/esm/src/wasm/startWasmServer.d.ts" + "main": "../dist/cjs/src/wasm/index.js", + "module": "../dist/esm/src/wasm/index.js", + "types": "../dist/esm/src/wasm/index.d.ts" }