Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions node/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
18 changes: 11 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
46 changes: 26 additions & 20 deletions src/CucumberLanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<TextDocument>,
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(
Expand All @@ -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()}`)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)}`
)
Expand All @@ -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)}`
)
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions src/Files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface Files {
exists(uri: string): Promise<boolean>
readFile(path: string): Promise<string>
findFiles(glob: string): Promise<readonly string[]>
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) || ''
}
33 changes: 20 additions & 13 deletions src/fs.ts
Original file line number Diff line number Diff line change
@@ -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<LanguageName, string[]> = {
tsx: ['.ts', '.tsx'],
Expand All @@ -25,38 +23,47 @@ const glueLanguageNameByExt = Object.fromEntries<LanguageName>(entries)
const glueExtensions = new Set(Object.keys(glueLanguageNameByExt))

export async function loadGlueSources(
files: Files,
globs: readonly string[]
): Promise<readonly Source<LanguageName>[]> {
return loadSources(globs, glueExtensions, glueLanguageNameByExt)
return loadSources(files, globs, glueExtensions, glueLanguageNameByExt)
}

export function getLanguage(ext: string): LanguageName | undefined {
return glueLanguageNameByExt[ext]
}

export async function loadGherkinSources(
files: Files,
globs: readonly string[]
): Promise<readonly Source<'gherkin'>[]> {
return loadSources(globs, new Set(['.feature']), { '.feature': 'gherkin' })
return loadSources(files, globs, new Set(['.feature']), { '.feature': 'gherkin' })
}

type LanguageNameByExt<L> = Record<string, L>

export async function findPaths(globs: readonly string[]): Promise<readonly string[]> {
const pathPromises = globs.reduce<readonly Promise<string[]>[]>((prev, glob) => {
return prev.concat(fg(glob, { caseSensitiveMatch: false, onlyFiles: true }))
export async function findPaths(
files: Files,
globs: readonly string[]
): Promise<readonly string[]> {
// Run all the globs in parallel
const pathsPromises = globs.reduce<readonly Promise<readonly string[]>[]>((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<L>(
files: Files,
globs: readonly string[],
extensions: Set<string>,
languageNameByExt: LanguageNameByExt<L>
): Promise<readonly Source<L>[]> {
const paths = await findPaths(globs)
const paths = await findPaths(files, globs)

return Promise.all(
paths
Expand All @@ -65,11 +72,11 @@ async function loadSources<L>(
(path) =>
new Promise<Source<L>>((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),
})
)
})
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './Files.js'
export * from './newWasmServer.js'
export * from './startServer.js'
30 changes: 30 additions & 0 deletions src/newWasmServer.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
39 changes: 39 additions & 0 deletions src/node/NodeFiles.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
try {
await fs.stat(new URL(uri))
return true
} catch {
return false
}
}

readFile(path: string): Promise<string> {
return fs.readFile(path, 'utf-8')
}

findFiles(glob: string): Promise<readonly string[]> {
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
}
}
2 changes: 2 additions & 0 deletions src/node/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './NodeFiles.js'
export * from './startNodeServer.js'
5 changes: 3 additions & 2 deletions src/node/startNodeServer.ts
Original file line number Diff line number Diff line change
@@ -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))
}
5 changes: 3 additions & 2 deletions src/startServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/wasm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './startWasmServer.js'
7 changes: 4 additions & 3 deletions src/wasm/startWasmServer.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading