diff --git a/.changeset/rotten-seahorses-fry.md b/.changeset/rotten-seahorses-fry.md new file mode 100644 index 00000000000..b1635cf4d52 --- /dev/null +++ b/.changeset/rotten-seahorses-fry.md @@ -0,0 +1,35 @@ +--- +'graphql-language-service-server': minor +'vscode-graphql': minor +'graphql-language-service-server-cli': minor +--- + +Fix many schema and fragment lifecycle issues, for all contexts except for schema updates for url schemas. +Note: this makes `cacheSchemaForLookup` enabled by default again for schema first contexts. + +this fixes multiple cacheing bugs, on writing some in-depth integration coverage for the LSP server. +it also solves several bugs regarding loading config types, and properly restarts the server when there are config changes + +### Bugfix Summary + +- jump to definition in embedded files offset bug +- cache invalidation for fragments +- schema cache invalidation for schema files +- schema definition lookups & autocomplete crossing into the wrong workspace + +### Known Bugs Fixed + +- #3318 +- #2357 +- #3469 +- #2422 +- #2820 +- many others to add here... + +### Test Improvements + +- new, high level integration spec suite for the LSP with a matching test utility +- more unit test coverage +- **total increased test coverage of about 25% in the LSP server codebase.** +- many "happy paths" covered for both schema and code first contexts +- many bugs revealed (and their source) diff --git a/.changeset/silly-yaks-bathe.md b/.changeset/silly-yaks-bathe.md new file mode 100644 index 00000000000..b7f2839f4e7 --- /dev/null +++ b/.changeset/silly-yaks-bathe.md @@ -0,0 +1,11 @@ +--- +'graphiql': patch +'graphql-language-service': patch +'graphql-language-service-server': patch +'graphql-language-service-server-cli': patch +'codemirror-graphql': patch +'@graphiql/react': patch +'monaco-graphql': patch +--- + +bugfix to completion for SDL type fields diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b6c35bd1a43..68ae3535a50 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -87,7 +87,7 @@ jobs: - run: yarn pretty-check jest: - name: Jest Unit Tests + name: Jest Unit & Integration Tests runs-on: ubuntu-latest needs: [install] steps: diff --git a/custom-words.txt b/custom-words.txt index 297692fd04b..953fcd65eda 100644 --- a/custom-words.txt +++ b/custom-words.txt @@ -65,6 +65,7 @@ yoshiakis // packages and tools argparse +arrayish astro astrojs changesets diff --git a/packages/graphiql/test/afterDevServer.js b/packages/graphiql/test/afterDevServer.js index d47ef13f274..cf055ee66be 100644 --- a/packages/graphiql/test/afterDevServer.js +++ b/packages/graphiql/test/afterDevServer.js @@ -10,4 +10,5 @@ module.exports = function afterDevServer(_app, _server, _compiler) { }); // eslint-disable-next-line react-hooks/rules-of-hooks useServer({ schema }, wsServer); + return wsServer; }; diff --git a/packages/graphiql/test/e2e-server.js b/packages/graphiql/test/e2e-server.js index a714e5be590..d3847bcad4e 100644 --- a/packages/graphiql/test/e2e-server.js +++ b/packages/graphiql/test/e2e-server.js @@ -43,7 +43,9 @@ app.post('/graphql-error/graphql', (_req, res, next) => { app.use(express.static(path.resolve(__dirname, '../'))); app.use('index.html', express.static(path.resolve(__dirname, '../dev.html'))); -app.listen(process.env.PORT || 0, function () { +// messy but it allows close +const server = require('node:http').createServer(app); +server.listen(process.env.PORT || 3100, function () { const { port } = this.address(); console.log(`Started on http://localhost:${port}/`); @@ -56,5 +58,7 @@ app.listen(process.env.PORT || 0, function () { process.exit(); }); }); +const wsServer = WebSocketsServer(); -WebSocketsServer(); +module.exports.server = server; +module.exports.wsServer = wsServer; diff --git a/packages/graphql-language-service-server/README.md b/packages/graphql-language-service-server/README.md index d0f5388cd9a..9022aaecea8 100644 --- a/packages/graphql-language-service-server/README.md +++ b/packages/graphql-language-service-server/README.md @@ -157,7 +157,7 @@ module.exports = { // note that this file will be loaded by the vscode runtime, so the node version and other factors will come into play customValidationRules: require('./config/customValidationRules'), languageService: { - // should the language service read schema for definition lookups from a cached file based on graphql config output? + // this is enabled by default if non-local files are specified in the project `schema` // NOTE: this will disable all definition lookup for local SDL files cacheSchemaFileForLookup: true, // undefined by default which has the same effect as `true`, set to `false` if you are already using // `graphql-eslint` or some other tool for validating graphql in your IDE. Must be explicitly `false` to disable this feature, not just "falsy" @@ -237,14 +237,14 @@ via `initializationOptions` in nvim.coc. The options are mostly designed to configure graphql-config's load parameters, the only thing we can't configure with graphql config. The final option can be set in `graphql-config` as well -| Parameter | Default | Description | -| ----------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `graphql-config.load.baseDir` | workspace root or process.cwd() | the path where graphql config looks for config files | -| `graphql-config.load.filePath` | `null` | exact filepath of the config file. | -| `graphql-config.load.configName` | `graphql` | config name prefix instead of `graphql` | -| `graphql-config.load.legacy` | `true` | backwards compatibility with `graphql-config@2` | -| `graphql-config.dotEnvPath` | `null` | backwards compatibility with `graphql-config@2` | -| `vscode-graphql.cacheSchemaFileForLookup` | `false` | generate an SDL file based on your graphql-config schema configuration for schema definition lookup and other features. useful when your `schema` config are urls | +| Parameter | Default | Description | +| ----------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `graphql-config.load.baseDir` | workspace root or process.cwd() | the path where graphql config looks for config files | +| `graphql-config.load.filePath` | `null` | exact filepath of the config file. | +| `graphql-config.load.configName` | `graphql` | config name prefix instead of `graphql` | +| `graphql-config.load.legacy` | `true` | backwards compatibility with `graphql-config@2` | +| `graphql-config.dotEnvPath` | `null` | backwards compatibility with `graphql-config@2` | +| `vscode-graphql.cacheSchemaFileForLookup` | `true` if `schema` contains non-sdl files or urls | generate an SDL file based on your graphql-config schema configuration for schema definition lookup and other features. enabled by default when your `schema` config are urls or introspection json, or if you have any non-local SDL files in `schema` | all the `graphql-config.load.*` configuration values come from static `loadConfig()` options in graphql config. diff --git a/packages/graphql-language-service-server/package.json b/packages/graphql-language-service-server/package.json index d4cd4eaceff..704fc323df5 100644 --- a/packages/graphql-language-service-server/package.json +++ b/packages/graphql-language-service-server/package.json @@ -42,6 +42,10 @@ "@babel/parser": "^7.23.6", "@babel/types": "^7.23.5", "@graphql-tools/code-file-loader": "8.0.3", + "@graphql-tools/graphql-tag-pluck": "^8.3.0", + "@graphql-tools/graphql-file-loader": "^8.0.1", + "@graphql-tools/url-loader": "^8.0.2", + "@graphql-tools/utils": "^10.1.2", "@vue/compiler-sfc": "^3.4.5", "astrojs-compiler-sync": "^0.3.5", "cosmiconfig-toml-loader": "^1.0.0", @@ -56,15 +60,16 @@ "source-map-js": "1.0.2", "svelte": "^4.1.1", "svelte2tsx": "^0.7.0", + "typescript": "^5.3.3", "vscode-jsonrpc": "^8.0.1", "vscode-languageserver": "^8.0.1", "vscode-languageserver-types": "^3.17.2", - "vscode-uri": "^3.0.2", - "typescript": "^5.3.3" + "vscode-uri": "^3.0.2" }, "devDependencies": { "@types/glob": "^8.1.0", "@types/mkdirp": "^1.0.1", + "@types/mock-fs": "^4.13.4", "cross-env": "^7.0.2", "graphql": "^16.8.1", "mock-fs": "^5.2.0" diff --git a/packages/graphql-language-service-server/src/GraphQLCache.ts b/packages/graphql-language-service-server/src/GraphQLCache.ts index f7b043e5676..ccb6dc07235 100644 --- a/packages/graphql-language-service-server/src/GraphQLCache.ts +++ b/packages/graphql-language-service-server/src/GraphQLCache.ts @@ -17,19 +17,21 @@ import { extendSchema, parse, visit, + Location, + Source as GraphQLSource, + printSchema, } from 'graphql'; import type { CachedContent, - GraphQLCache as GraphQLCacheInterface, - GraphQLFileMetadata, GraphQLFileInfo, FragmentInfo, ObjectTypeInfo, Uri, + IRange, } from 'graphql-language-service'; - -import * as fs from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { gqlPluckFromCodeString } from '@graphql-tools/graphql-tag-pluck'; +import { Position, Range } from 'graphql-language-service'; +import { readFile, stat, writeFile } from 'node:fs/promises'; import nullthrows from 'nullthrows'; import { @@ -37,7 +39,10 @@ import { GraphQLConfig, GraphQLProjectConfig, GraphQLExtensionDeclaration, + DocumentPointer, + SchemaPointer, } from 'graphql-config'; +import { Source } from '@graphql-tools/utils'; import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load'; @@ -47,34 +52,63 @@ import glob from 'glob'; import { LoadConfigOptions } from './types'; import { URI } from 'vscode-uri'; import { CodeFileLoader } from '@graphql-tools/code-file-loader'; +import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; +import { UrlLoader } from '@graphql-tools/url-loader'; + import { DEFAULT_SUPPORTED_EXTENSIONS, DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, + SupportedExtensionsEnum, } from './constants'; import { NoopLogger, Logger } from './Logger'; +import path, { extname, resolve } from 'node:path'; +import { TextDocumentContentChangeEvent } from 'vscode-languageserver'; +import { existsSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; const LanguageServiceExtension: GraphQLExtensionDeclaration = api => { // For schema api.loaders.schema.register(new CodeFileLoader()); + api.loaders.schema.register(new GraphQLFileLoader()); + api.loaders.schema.register(new UrlLoader()); // For documents api.loaders.documents.register(new CodeFileLoader()); + api.loaders.documents.register(new GraphQLFileLoader()); + api.loaders.documents.register(new UrlLoader()); return { name: 'languageService' }; }; // Maximum files to read when processing GraphQL files. -const MAX_READS = 200; + +const graphqlRangeFromLocation = (location: Location): Range => { + const locOffset = location.source.locationOffset; + const start = location.startToken; + const end = location.endToken; + return new Range( + new Position( + start.line + locOffset.line - 1, + start.column + locOffset.column - 1, + ), + new Position( + end.line + locOffset.line - 1, + end.column + locOffset.column - 1, + ), + ); +}; export async function getGraphQLCache({ parser, logger, loadConfigOptions, config, + settings, }: { parser: typeof parseDocument; logger: Logger | NoopLogger; loadConfigOptions: LoadConfigOptions; config?: GraphQLConfig; + settings?: Record; }): Promise { const graphQLConfig = config || @@ -90,30 +124,38 @@ export async function getGraphQLCache({ config: graphQLConfig!, parser, logger, + settings, }); } -export class GraphQLCache implements GraphQLCacheInterface { +export class GraphQLCache { _configDir: Uri; _graphQLFileListCache: Map>; _graphQLConfig: GraphQLConfig; - _schemaMap: Map; + _schemaMap: Map; _typeExtensionMap: Map; _fragmentDefinitionsCache: Map>; _typeDefinitionsCache: Map>; _parser: typeof parseDocument; _logger: Logger | NoopLogger; + private _tmpDir: any; + private _tmpDirBase: string; + private _settings?: Record; constructor({ configDir, config, parser, logger, + tmpDir, + settings, }: { configDir: Uri; config: GraphQLConfig; parser: typeof parseDocument; logger: Logger | NoopLogger; + tmpDir?: string; + settings?: Record; }) { this._configDir = configDir; this._graphQLConfig = config; @@ -124,13 +166,29 @@ export class GraphQLCache implements GraphQLCacheInterface { this._typeExtensionMap = new Map(); this._parser = parser; this._logger = logger; + this._tmpDir = tmpDir || tmpdir(); + this._tmpDirBase = path.join(this._tmpDir, 'graphql-language-service'); + this._settings = settings; } getGraphQLConfig = (): GraphQLConfig => this._graphQLConfig; + /** + * + * @param uri system protocol path for the file, e.g. file:///path/to/file + * @returns + */ getProjectForFile = (uri: string): GraphQLProjectConfig | void => { try { - return this._graphQLConfig.getProjectForFile(URI.parse(uri).fsPath); + const project = this._graphQLConfig.getProjectForFile( + URI.parse(uri).fsPath, + ); + if (!project.documents) { + this._logger.warn( + `No documents configured for project ${project.name}. Many features will not work correctly.`, + ); + } + return project; } catch (err) { this._logger.error( `there was an error loading the project config for this file ${err}`, @@ -138,6 +196,155 @@ export class GraphQLCache implements GraphQLCacheInterface { return; } }; + private _getTmpProjectPath( + project: GraphQLProjectConfig, + prependWithProtocol = true, + appendPath?: string, + ) { + const baseDir = this.getGraphQLConfig().dirpath; + const workspaceName = path.basename(baseDir); + const basePath = path.join(this._tmpDirBase, workspaceName); + let projectTmpPath = path.join(basePath, 'projects', project.name); + if (!existsSync(projectTmpPath)) { + mkdirSync(projectTmpPath, { + recursive: true, + }); + } + if (appendPath) { + projectTmpPath = path.join(projectTmpPath, appendPath); + } + if (prependWithProtocol) { + return URI.file(path.resolve(projectTmpPath)).toString(); + } + return path.resolve(projectTmpPath); + } + private _unwrapProjectSchema(project: GraphQLProjectConfig): string[] { + const projectSchema = project.schema; + + const schemas: string[] = []; + if (typeof projectSchema === 'string') { + schemas.push(projectSchema); + } else if (Array.isArray(projectSchema)) { + for (const schemaEntry of projectSchema) { + if (typeof schemaEntry === 'string') { + schemas.push(schemaEntry); + } else if (schemaEntry) { + schemas.push(...Object.keys(schemaEntry)); + } + } + } else { + schemas.push(...Object.keys(projectSchema)); + } + + return schemas.reduce((agg, schema) => { + const results = this._globIfFilePattern(schema); + return [...agg, ...results]; + }, []); + } + private _globIfFilePattern(pattern: string) { + if (pattern.includes('*')) { + try { + return glob.sync(pattern); + // URLs may contain * characters + } catch {} + } + return [pattern]; + } + + private async _cacheConfigSchema(project: GraphQLProjectConfig) { + try { + const schema = await this.getSchema(project.name); + if (schema) { + let schemaText = printSchema(schema); + // file:// protocol path + const uri = this._getTmpProjectPath( + project, + true, + 'generated-schema.graphql', + ); + + // no file:// protocol for fs.writeFileSync() + const fsPath = this._getTmpProjectPath( + project, + false, + 'generated-schema.graphql', + ); + schemaText = `# This is an automatically generated representation of your schema.\n# Any changes to this file will be overwritten and will not be\n# reflected in the resulting GraphQL schema\n\n${schemaText}`; + + const cachedSchemaDoc = this._getCachedDocument(uri, project); + this._schemaMap.set( + this._getSchemaCacheKeyForProject(project) as string, + { schema, localUri: uri }, + ); + if (!cachedSchemaDoc) { + await writeFile(fsPath, schemaText, 'utf8'); + await this._cacheSchemaText(uri, schemaText, 0, project); + } + // do we have a change in the getSchema result? if so, update schema cache + if (cachedSchemaDoc) { + await writeFile(fsPath, schemaText, 'utf8'); + await this._cacheSchemaText( + uri, + schemaText, + cachedSchemaDoc.version++, + project, + ); + } + return { + uri, + fsPath, + }; + } + } catch (err) { + this._logger.error(String(err)); + } + } + _cacheSchemaText = async ( + uri: string, + text: string, + version: number, + project: GraphQLProjectConfig, + ) => { + const projectCacheKey = this._cacheKeyForProject(project); + const projectCache = this._graphQLFileListCache.get(projectCacheKey); + const ast = parse(text); + if (projectCache) { + const lines = text.split('\n'); + projectCache.set(uri, { + filePath: uri, + fsPath: URI.parse(uri).fsPath, + source: text, + contents: [ + { + documentString: text, + ast, + range: new Range( + new Position(0, 0), + new Position(lines.length, lines.at(-1)?.length ?? 0), + ), + }, + ], + mtime: Math.trunc(new Date().getTime() / 1000), + size: text.length, + version, + }); + + projectCache.delete(project.schema.toString()); + + this._setDefinitionCache( + [{ documentString: text, ast, range: undefined }], + this._typeDefinitionsCache.get(projectCacheKey) || new Map(), + uri, + ); + this._graphQLFileListCache.set(projectCacheKey, projectCache); + } + }; + + _getCachedDocument(uri: string, project: GraphQLProjectConfig) { + const projectCacheKey = this._cacheKeyForProject(project); + const projectCache = this._graphQLFileListCache.get(projectCacheKey); + return projectCache?.get(uri); + } getFragmentDependencies = async ( query: string, @@ -150,17 +357,20 @@ export class GraphQLCache implements GraphQLCacheInterface { } // If the query cannot be parsed, validations cannot happen yet. // Return an empty array. - let parsedQuery; + let parsedDocument; try { - parsedQuery = parse(query); + parsedDocument = parse(query); } catch { return []; } - return this.getFragmentDependenciesForAST(parsedQuery, fragmentDefinitions); + return this.getFragmentDependenciesForAST( + parsedDocument, + fragmentDefinitions, + ); }; getFragmentDependenciesForAST = async ( - parsedQuery: ASTNode, + parsedDocument: ASTNode, fragmentDefinitions: Map, ): Promise => { if (!fragmentDefinitions) { @@ -170,7 +380,7 @@ export class GraphQLCache implements GraphQLCacheInterface { const existingFrags = new Map(); const referencedFragNames = new Set(); - visit(parsedQuery, { + visit(parsedDocument, { FragmentDefinition(node) { existingFrags.set(node.name.value, true); }, @@ -225,42 +435,16 @@ export class GraphQLCache implements GraphQLCacheInterface { return this._fragmentDefinitionsCache.get(cacheKey) || new Map(); } - const list = await this._readFilesFromInputDirs(rootDir, projectConfig); - const { fragmentDefinitions, graphQLFileMap } = - await this.readAllGraphQLFiles(list); - + await this._buildCachesFromInputDirs(rootDir, projectConfig); this._fragmentDefinitionsCache.set(cacheKey, fragmentDefinitions); this._graphQLFileListCache.set(cacheKey, graphQLFileMap); return fragmentDefinitions; }; - getObjectTypeDependencies = async ( - query: string, - objectTypeDefinitions?: Map, - ): Promise> => { - // If there isn't context for object type references, - // return an empty array. - if (!objectTypeDefinitions) { - return []; - } - // If the query cannot be parsed, validations cannot happen yet. - // Return an empty array. - let parsedQuery; - try { - parsedQuery = parse(query); - } catch { - return []; - } - return this.getObjectTypeDependenciesForAST( - parsedQuery, - objectTypeDefinitions, - ); - }; - getObjectTypeDependenciesForAST = async ( - parsedQuery: ASTNode, + parsedDocument: ASTNode, objectTypeDefinitions: Map, ): Promise> => { if (!objectTypeDefinitions) { @@ -270,7 +454,7 @@ export class GraphQLCache implements GraphQLCacheInterface { const existingObjectTypes = new Map(); const referencedObjectTypes = new Set(); - visit(parsedQuery, { + visit(parsedDocument, { ObjectTypeDefinition(node) { existingObjectTypes.set(node.name.value, true); }, @@ -291,6 +475,7 @@ export class GraphQLCache implements GraphQLCacheInterface { ScalarTypeDefinition(node) { existingObjectTypes.set(node.name.value, true); }, + InterfaceTypeDefinition(node) { existingObjectTypes.set(node.name.value, true); }, @@ -335,137 +520,426 @@ export class GraphQLCache implements GraphQLCacheInterface { if (this._typeDefinitionsCache.has(cacheKey)) { return this._typeDefinitionsCache.get(cacheKey) || new Map(); } - const list = await this._readFilesFromInputDirs(rootDir, projectConfig); const { objectTypeDefinitions, graphQLFileMap } = - await this.readAllGraphQLFiles(list); + await this._buildCachesFromInputDirs(rootDir, projectConfig); this._typeDefinitionsCache.set(cacheKey, objectTypeDefinitions); this._graphQLFileListCache.set(cacheKey, graphQLFileMap); return objectTypeDefinitions; }; - _readFilesFromInputDirs = ( - rootDir: string, - projectConfig: GraphQLProjectConfig, - ): Promise> => { - let pattern: string; - const patterns = this._getSchemaAndDocumentFilePatterns(projectConfig); - - // See https://github.com/graphql/graphql-language-service/issues/221 - // for details on why special handling is required here for the - // documents.length === 1 case. - if (patterns.length === 1) { - // @ts-ignore - pattern = patterns[0]; - } else { - pattern = `{${patterns.join(',')}}`; - } - - return new Promise((resolve, reject) => { - const globResult = new glob.Glob( - pattern, - { - cwd: rootDir, - stat: true, - absolute: false, - ignore: [ - 'generated/relay', - '**/__flow__/**', - '**/__generated__/**', - '**/__github__/**', - '**/__mocks__/**', - '**/node_modules/**', - '**/__flowtests__/**', - ], - }, - error => { - if (error) { - reject(error); - } - }, - ); - globResult.on('end', () => { - resolve( - Object.keys(globResult.statCache) - .filter( - filePath => typeof globResult.statCache[filePath] === 'object', - ) - .filter(filePath => projectConfig.match(filePath)) - .map(filePath => { - // @TODO - // so we have to force this here - // because glob's DefinitelyTyped doesn't use fs.Stats here though - // the docs indicate that is what's there :shrug: - const cacheEntry = globResult.statCache[filePath] as fs.Stats; - return { - filePath: URI.file(filePath).toString(), - mtime: Math.trunc(cacheEntry.mtime.getTime() / 1000), - size: cacheEntry.size, - }; - }), + private async loadTypeDefs( + project: GraphQLProjectConfig, + pointer: + | DocumentPointer + | SchemaPointer + | UnnormalizedTypeDefPointer + | UnnormalizedTypeDefPointer[] + | DocumentPointer[] + | SchemaPointer[] + | string, + target: 'documents' | 'schema', + ): Promise { + if (typeof pointer === 'string') { + try { + const { fsPath } = URI.parse(pointer); + // @ts-expect-error these are always here. better typings soon + + return project._extensionsRegistry.loaders[target].loadTypeDefs( + fsPath, + { + cwd: project.dirpath, + includes: project.include, + excludes: project.exclude, + includeSources: true, + assumeValid: false, + noLocation: false, + assumeValidSDL: false, + }, ); - }); + } catch {} + } + // @ts-expect-error these are always here. better typings soon + return project._extensionsRegistry.loaders[target].loadTypeDefs(pointer, { + cwd: project.dirpath, + includes: project.include, + excludes: project.exclude, + includeSources: true, + assumeValid: false, + noLocation: false, + assumeValidSDL: false, }); - }; + } - _getSchemaAndDocumentFilePatterns = (projectConfig: GraphQLProjectConfig) => { - const patterns: string[] = []; + public async readAndCacheFile( + uri: string, + changes?: TextDocumentContentChangeEvent[], + ): Promise<{ + project?: GraphQLProjectConfig; + projectCacheKey?: string; + contents?: Array; + } | null> { + const project = this.getProjectForFile(uri); + if (!project) { + return null; + } + let fileContents = null; + const projectCacheKey = this._cacheKeyForProject(project); + // on file change, patch the file with the changes + // so we can handle any potential new graphql content (as well as re-compute offsets for code files) + // before the changes have been saved to the file system + if (changes) { + // TODO: move this to a standalone function with unit tests! - for (const pointer of [projectConfig.documents, projectConfig.schema]) { - if (pointer) { - if (typeof pointer === 'string') { - patterns.push(pointer); - } else if (Array.isArray(pointer)) { - patterns.push(...pointer); + try { + const fileText = await readFile(URI.parse(uri).fsPath, { + encoding: 'utf-8', + }); + let newFileText = fileText; + for (const change of changes) { + if ('range' in change) { + // patch file with change range and text + const { start, end } = change.range; + const lines = newFileText.split('\n'); + const startLine = start.line; + const endLine = end.line; + + const before = lines.slice(0, startLine).join('\n'); + const after = lines.slice(endLine + 1).join('\n'); + + newFileText = `${before}${change.text}${after}`; + } + } + if ( + DEFAULT_SUPPORTED_EXTENSIONS.includes( + extname(uri) as SupportedExtensionsEnum, + ) + ) { + const result = await gqlPluckFromCodeString(uri, newFileText); + + fileContents = result.map(plucked => { + const source = new GraphQLSource(plucked.body, plucked.name); + source.locationOffset = plucked.locationOffset; + let document = null; + try { + document = parse(source); + } catch (err) { + console.error(err); + return; + } + + const lines = plucked.body.split('\n'); + return { + rawSDL: plucked.body, + document, + range: graphqlRangeFromLocation({ + source: { + body: plucked.body, + locationOffset: plucked.locationOffset, + name: plucked.name, + }, + startToken: { + line: 0, + column: 0, + }, + endToken: { + line: lines.length, + column: lines.at(-1)?.length ?? 0, + }, + }), + }; + }); + } else if ( + DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS.includes( + extname(uri) as SupportedExtensionsEnum, + ) + ) { + try { + const source = new GraphQLSource(newFileText, uri); + const lines = newFileText.split('\n'); + fileContents = [ + { + rawSDL: newFileText, + document: parse(source), + range: { + start: { line: 0, character: 0 }, + end: { + line: lines.length, + character: lines.at(-1)?.length, + }, + }, + }, + ]; + } catch (err) { + console.error(err); + } + } + } catch (err) { + console.error(err); + } + } else { + try { + fileContents = await this.loadTypeDefs(project, uri, 'schema'); + } catch { + fileContents = this._parser( + await readFile(URI.parse(uri).fsPath, { encoding: 'utf-8' }), + uri, + ).map(c => { + return { + rawSDL: c.documentString, + document: c.ast, + range: c.range, + }; + }); + } + if (!fileContents?.length) { + try { + fileContents = await this.loadTypeDefs(project, uri, 'documents'); + } catch { + fileContents = this._parser( + await readFile(URI.parse(uri).fsPath, { encoding: 'utf-8' }), + uri, + ).map(c => { + return { + rawSDL: c.documentString, + document: c.ast, + range: c.range, + }; + }); } } } + if (!fileContents?.length) { + return null; + } - return patterns; - }; + const asts = fileContents + .map(doc => { + return { + ast: doc?.document, + documentString: doc?.document?.loc?.source.body ?? doc?.rawSDL, + range: doc?.document?.loc + ? graphqlRangeFromLocation(doc?.document?.loc) + : // @ts-expect-error + doc?.range ?? null, + }; + }) + .filter(doc => Boolean(doc.documentString)) as { + documentString: string; + ast?: DocumentNode; + range?: IRange; + }[]; + + this._setFragmentCache( + asts, + this._fragmentDefinitionsCache.get(projectCacheKey) || new Map(), + uri, + ); - async _updateGraphQLFileListCache( - graphQLFileMap: Map, - metrics: { size: number; mtime: number }, - filePath: Uri, - exists: boolean, - ): Promise> { - const fileAndContent = exists - ? await this.promiseToReadGraphQLFile(filePath) - : null; - - const existingFile = graphQLFileMap.get(filePath); - - // 3 cases for the cache invalidation: create/modify/delete. - // For create/modify, swap the existing entry if available; - // otherwise, just push in the new entry created. - // For delete, check `exists` and splice the file out. - if (existingFile && !exists) { - graphQLFileMap.delete(filePath); - } else if (fileAndContent) { - const graphQLFileInfo = { ...fileAndContent, ...metrics }; - graphQLFileMap.set(filePath, graphQLFileInfo); - } - - return graphQLFileMap; + this._setDefinitionCache( + asts, + this._typeDefinitionsCache.get(projectCacheKey) || new Map(), + uri, + ); + const { fsPath } = URI.parse(uri); + const stats = await stat(fsPath); + const source = await readFile(fsPath, { encoding: 'utf-8' }); + const projectFileCache = + this._graphQLFileListCache.get(projectCacheKey) ?? new Map(); + const cachedDoc = projectFileCache?.get(uri); + + projectFileCache?.set(uri, { + filePath: uri, + fsPath, + source, + contents: asts, + mtime: Math.trunc(stats.mtime.getTime() / 1000), + size: stats.size, + version: cachedDoc?.version ? cachedDoc.version++ : 0, + }); + + return { + project, + projectCacheKey, + contents: asts, + }; } + _buildCachesFromInputDirs = async ( + rootDir: string, + projectConfig: GraphQLProjectConfig, + options?: { maxReads?: number; schemaOnly?: boolean }, + ): Promise<{ + objectTypeDefinitions: Map; + fragmentDefinitions: Map; + graphQLFileMap: Map; + }> => { + try { + let documents: Source[] = []; + + if (!options?.schemaOnly && projectConfig.documents) { + try { + documents = await this.loadTypeDefs( + projectConfig, + projectConfig.documents, + 'documents', + ); + } catch (err) { + this._logger.log(String(err)); + } + } + + let schemaDocuments: Source[] = []; + // cache schema files + try { + schemaDocuments = await this.loadTypeDefs( + projectConfig, + projectConfig.schema, + 'schema', + ); + } catch (err) { + this._logger.log(String(err)); + } + + // console.log('schemaDocuments', schemaDocuments); + + documents = [...documents, ...schemaDocuments]; + const graphQLFileMap = new Map(); + const fragmentDefinitions = new Map(); + const objectTypeDefinitions = new Map(); + await Promise.all( + documents.map(async doc => { + if (!doc.rawSDL || !doc.document || !doc.location) { + return; + } + let fsPath = doc.location; + let filePath; + const isNetwork = doc.location.startsWith('http'); + if (!isNetwork) { + try { + fsPath = resolve(rootDir, doc.location); + } catch {} + filePath = URI.file(fsPath).toString(); + } else { + filePath = this._getTmpProjectPath( + projectConfig, + true, + 'generated-schema.graphql', + ); + fsPath = this._getTmpProjectPath( + projectConfig, + false, + 'generated-schema.graphql', + ); + } + + const content = doc.document.loc?.source.body ?? ''; + for (const definition of doc.document.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragmentDefinitions.set(definition.name.value, { + filePath, + fsPath, + content, + definition, + }); + } else if (isTypeDefinitionNode(definition)) { + objectTypeDefinitions.set(definition.name.value, { + // uri: filePath, + filePath, + fsPath, + content, + definition, + }); + } + } + // console.log(graphqlRangeFromLocation(doc.document.loc)); + if (graphQLFileMap.has(filePath)) { + const cachedEntry = graphQLFileMap.get(filePath)!; + graphQLFileMap.set(filePath, { + ...cachedEntry, + source: content, + contents: [ + ...cachedEntry.contents, + { + ast: doc.document, + documentString: doc.document.loc?.source.body ?? doc.rawSDL, + range: doc.document.loc + ? graphqlRangeFromLocation(doc.document.loc) + : null, + }, + ], + }); + } else { + let mtime = new Date(); + let size = 0; + if (!isNetwork) { + try { + const stats = await stat(fsPath); + mtime = stats.mtime; + size = stats.size; + } catch {} + } + + graphQLFileMap.set(filePath, { + filePath, + fsPath, + source: content, + version: 0, + contents: [ + { + ast: doc.document, + documentString: doc.document.loc?.source.body ?? doc.rawSDL, + range: doc.document.loc + ? graphqlRangeFromLocation(doc.document.loc) + : null, + }, + ], + mtime: Math.trunc(mtime.getTime() / 1000), + size, + }); + } + }), + ); + + return { + graphQLFileMap, + fragmentDefinitions, + objectTypeDefinitions, + }; + } catch (err) { + this._logger.error(`Error building caches from input dirs: ${err}`); + return { + graphQLFileMap: new Map(), + fragmentDefinitions: new Map(), + objectTypeDefinitions: new Map(), + }; + } + }; + async updateFragmentDefinition( - rootDir: Uri, + projectCacheKey: Uri, filePath: Uri, contents: Array, ): Promise { - const cache = this._fragmentDefinitionsCache.get(rootDir); - const asts = contents.map(({ query }) => { - try { - return { - ast: parse(query), - query, - }; - } catch { - return { ast: null, query }; - } - }); + const cache = this._fragmentDefinitionsCache.get(projectCacheKey); + const asts = contents + .map(({ documentString, range, ast }) => { + try { + return { + ast: ast ?? parse(documentString), + documentString, + range, + }; + } catch { + return { ast: null, documentString, range }; + } + }) + .filter(doc => Boolean(doc.documentString)) as { + documentString: string; + ast?: DocumentNode; + range?: IRange; + }[]; + if (cache) { // first go through the fragment list to delete the ones from this file for (const [key, value] of cache.entries()) { @@ -473,63 +947,53 @@ export class GraphQLCache implements GraphQLCacheInterface { cache.delete(key); } } - for (const { ast, query } of asts) { - if (!ast) { - continue; - } - for (const definition of ast.definitions) { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - cache.set(definition.name.value, { - filePath, - content: query, - definition, - }); - } - } - } + this._setFragmentCache(asts, cache, filePath); + } else { + const newFragmentCache = this._setFragmentCache( + asts, + new Map(), + filePath, + ); + this._fragmentDefinitionsCache.set(projectCacheKey, newFragmentCache); } } - - async updateFragmentDefinitionCache( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ): Promise { - const fileAndContent = exists - ? await this.promiseToReadGraphQLFile(filePath) - : null; - // In the case of fragment definitions, the cache could just map the - // definition name to the parsed ast, whether or not it existed - // previously. - // For delete, remove the entry from the set. - if (!exists) { - const cache = this._fragmentDefinitionsCache.get(rootDir); - if (cache) { - cache.delete(filePath); + _setFragmentCache( + asts: CachedContent[], + fragmentCache: Map, + filePath: string | undefined, + ) { + for (const { ast, documentString } of asts) { + if (!ast) { + continue; + } + for (const definition of ast.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragmentCache.set(definition.name.value, { + filePath, + content: documentString, + definition, + }); + } } - } else if (fileAndContent?.queries) { - await this.updateFragmentDefinition( - rootDir, - filePath, - fileAndContent.queries, - ); } + return fragmentCache; } async updateObjectTypeDefinition( - rootDir: Uri, + projectCacheKey: Uri, filePath: Uri, contents: Array, ): Promise { - const cache = this._typeDefinitionsCache.get(rootDir); - const asts = contents.map(({ query }) => { + const cache = this._typeDefinitionsCache.get(projectCacheKey); + const asts = contents.map(({ documentString, range, ast }) => { try { return { - ast: parse(query), - query, + ast, + documentString, + range: range ?? null, }; } catch { - return { ast: null, query }; + return { ast: null, documentString, range: range ?? null }; } }); if (cache) { @@ -539,47 +1003,34 @@ export class GraphQLCache implements GraphQLCacheInterface { cache.delete(key); } } - for (const { ast, query } of asts) { - if (!ast) { - continue; - } - for (const definition of ast.definitions) { - if (isTypeDefinitionNode(definition)) { - cache.set(definition.name.value, { - filePath, - content: query, - definition, - }); - } - } - } + this._setDefinitionCache(asts, cache, filePath); + } else { + const newTypeCache = this._setDefinitionCache(asts, new Map(), filePath); + this._typeDefinitionsCache.set(projectCacheKey, newTypeCache); } } - - async updateObjectTypeDefinitionCache( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ): Promise { - const fileAndContent = exists - ? await this.promiseToReadGraphQLFile(filePath) - : null; - // In the case of type definitions, the cache could just map the - // definition name to the parsed ast, whether or not it existed - // previously. - // For delete, remove the entry from the set. - if (!exists) { - const cache = this._typeDefinitionsCache.get(rootDir); - if (cache) { - cache.delete(filePath); + _setDefinitionCache( + asts: CachedContent[], + typeCache: Map, + filePath: string | undefined, + ) { + for (const { ast, documentString } of asts) { + if (!ast) { + continue; + } + for (const definition of ast.definitions) { + if (isTypeDefinitionNode(definition)) { + typeCache.set(definition.name.value, { + filePath, + uri: filePath, + fsPath: filePath, + content: documentString, + definition, + }); + } } - } else if (fileAndContent?.queries) { - await this.updateObjectTypeDefinition( - rootDir, - filePath, - fileAndContent.queries, - ); } + return typeCache; } _extendSchema( @@ -593,8 +1044,11 @@ export class GraphQLCache implements GraphQLCacheInterface { if (!graphQLFileMap) { return schema; } - for (const { filePath, asts } of graphQLFileMap.values()) { - for (const ast of asts) { + for (const { filePath, contents } of graphQLFileMap.values()) { + for (const { ast } of contents) { + if (!ast) { + continue; + } if (filePath === schemaPath) { continue; } @@ -645,10 +1099,10 @@ export class GraphQLCache implements GraphQLCacheInterface { } getSchema = async ( - appName?: string, + projectName: string, queryHasExtensions?: boolean | null, ): Promise => { - const projectConfig = this._graphQLConfig.getProject(appName); + const projectConfig = this._graphQLConfig.getProject(projectName); if (!projectConfig) { return null; @@ -658,45 +1112,89 @@ export class GraphQLCache implements GraphQLCacheInterface { const schemaKey = this._getSchemaCacheKeyForProject(projectConfig); let schemaCacheKey = null; - let schema = null; + let schema: { schema?: GraphQLSchema; localUri?: string } = {}; if (schemaPath && schemaKey) { schemaCacheKey = schemaKey as string; - // Maybe use cache if (this._schemaMap.has(schemaCacheKey)) { - schema = this._schemaMap.get(schemaCacheKey); - if (schema) { + schema = this._schemaMap.get(schemaCacheKey) as { + schema: GraphQLSchema; + localUri?: string; + }; + if (schema.schema) { return queryHasExtensions - ? this._extendSchema(schema, schemaPath, schemaCacheKey) - : schema; + ? this._extendSchema(schema.schema, schemaPath, schemaCacheKey) + : schema.schema; } } + try { + // Read from disk + schema.schema = await projectConfig.getSchema(); + } catch { + // // if there is an error reading the schema, just use the last valid schema + schema = this._schemaMap.get(schemaCacheKey)!; + } + } - // Read from disk - schema = await projectConfig.getSchema(); + if (!schema.schema) { + return null; } const customDirectives = projectConfig?.extensions?.customDirectives; - if (customDirectives && schema) { + if (customDirectives) { const directivesSDL = customDirectives.join('\n\n'); - schema = extendSchema(schema, parse(directivesSDL)); - } - - if (!schema) { - return null; + schema.schema = extendSchema(schema.schema, parse(directivesSDL)); } if (this._graphQLFileListCache.has(this._configDir)) { - schema = this._extendSchema(schema, schemaPath, schemaCacheKey); + schema.schema = this._extendSchema( + schema.schema, + schemaPath, + schemaCacheKey, + ); } if (schemaCacheKey) { - this._schemaMap.set(schemaCacheKey, schema); + this._schemaMap.set( + schemaCacheKey, + schema as { + schema: GraphQLSchema; + localUri?: string; + }, + ); + await this.maybeCacheSchemaFile(projectConfig); } - return schema; + return schema.schema; }; - + private async maybeCacheSchemaFile(projectConfig: GraphQLProjectConfig) { + const cacheSchemaFileForLookup = + projectConfig.extensions.languageService?.cacheSchemaFileForLookup ?? + this?._settings?.cacheSchemaFileForLookup ?? + true; + const unwrappedSchema = this._unwrapProjectSchema(projectConfig); + const allExtensions = [ + ...DEFAULT_SUPPORTED_EXTENSIONS, + ...DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, + ]; + + // // only local schema lookups if all of the schema entries are local files + const sdlOnly = unwrappedSchema.every(schemaEntry => + allExtensions.some( + // local schema file URIs for lookup don't start with http, and end with an extension. + // though it isn't often used, technically schema config could include a remote .graphql file + ext => !schemaEntry.startsWith('http') && schemaEntry.endsWith(ext), + ), + ); + if (!sdlOnly && cacheSchemaFileForLookup) { + const result = await this._cacheConfigSchema(projectConfig); + if (result) { + const { uri, fsPath } = result; + return { uri, fsPath, sdlOnly }; + } + } + return { sdlOnly }; + } invalidateSchemaCacheForProject(projectConfig: GraphQLProjectConfig) { const schemaKey = this._getSchemaCacheKeyForProject( projectConfig, @@ -713,167 +1211,6 @@ export class GraphQLCache implements GraphQLCacheInterface { } _getProjectName(projectConfig: GraphQLProjectConfig) { - return projectConfig || 'default'; + return projectConfig?.name || 'default'; } - - /** - * Given a list of GraphQL file metadata, read all files collected from watchman - * and create fragmentDefinitions and GraphQL files cache. - */ - readAllGraphQLFiles = async ( - list: Array, - ): Promise<{ - objectTypeDefinitions: Map; - fragmentDefinitions: Map; - graphQLFileMap: Map; - }> => { - const queue = list.slice(); // copy - const responses: GraphQLFileInfo[] = []; - while (queue.length) { - const chunk = queue.splice(0, MAX_READS); - const promises = chunk.map(async fileInfo => { - try { - const response = await this.promiseToReadGraphQLFile( - fileInfo.filePath, - ); - responses.push({ - ...response, - mtime: fileInfo.mtime, - size: fileInfo.size, - }); - } catch (error: any) { - // eslint-disable-next-line no-console - console.log('pro', error); - /** - * fs emits `EMFILE | ENFILE` error when there are too many - * open files - this can cause some fragment files not to be - * processed. Solve this case by implementing a queue to save - * files failed to be processed because of `EMFILE` error, - * and await on Promises created with the next batch from the - * queue. - */ - if (error.code === 'EMFILE' || error.code === 'ENFILE') { - queue.push(fileInfo); - } - } - }); - await Promise.all(promises); // eslint-disable-line no-await-in-loop - } - - return this.processGraphQLFiles(responses); - }; - - /** - * Takes an array of GraphQL File information and batch-processes into a - * map of fragmentDefinitions and GraphQL file cache. - */ - processGraphQLFiles = ( - responses: Array, - ): { - objectTypeDefinitions: Map; - fragmentDefinitions: Map; - graphQLFileMap: Map; - } => { - const objectTypeDefinitions = new Map(); - const fragmentDefinitions = new Map(); - const graphQLFileMap = new Map(); - - for (const response of responses) { - const { filePath, content, asts, mtime, size } = response; - - if (asts) { - for (const ast of asts) { - for (const definition of ast.definitions) { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - fragmentDefinitions.set(definition.name.value, { - filePath, - content, - definition, - }); - } else if (isTypeDefinitionNode(definition)) { - objectTypeDefinitions.set(definition.name.value, { - filePath, - content, - definition, - }); - } - } - } - } - - // Relay the previous object whether or not ast exists. - graphQLFileMap.set(filePath, { - filePath, - content, - asts, - mtime, - size, - }); - } - - return { - objectTypeDefinitions, - fragmentDefinitions, - graphQLFileMap, - }; - }; - - /** - * Returns a Promise to read a GraphQL file and return a GraphQL metadata - * including a parsed AST. - */ - promiseToReadGraphQLFile = async ( - filePath: Uri, - ): Promise => { - const content = await readFile(URI.parse(filePath).fsPath, 'utf8'); - - const asts: DocumentNode[] = []; - let queries: CachedContent[] = []; - if (content.trim().length !== 0) { - try { - queries = this._parser( - content, - filePath, - DEFAULT_SUPPORTED_EXTENSIONS, - DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, - this._logger, - ); - if (queries.length === 0) { - // still resolve with an empty ast - return { - filePath, - content, - asts: [], - queries: [], - mtime: 0, - size: 0, - }; - } - - for (const { query } of queries) { - asts.push(parse(query)); - } - return { - filePath, - content, - asts, - queries, - mtime: 0, - size: 0, - }; - } catch { - // If query has syntax errors, go ahead and still resolve - // the filePath and the content, but leave ast empty. - return { - filePath, - content, - asts: [], - queries: [], - mtime: 0, - size: 0, - }; - } - } - return { filePath, content, asts, queries, mtime: 0, size: 0 }; - }; } diff --git a/packages/graphql-language-service-server/src/GraphQLLanguageService.ts b/packages/graphql-language-service-server/src/GraphQLLanguageService.ts index aeaa4c92a8e..e0392f285c1 100644 --- a/packages/graphql-language-service-server/src/GraphQLLanguageService.ts +++ b/packages/graphql-language-service-server/src/GraphQLLanguageService.ts @@ -29,7 +29,6 @@ import { IPosition, Outline, OutlineTree, - GraphQLCache, getAutocompleteSuggestions, getHoverInformation, HoverConfig, @@ -47,6 +46,8 @@ import { getTypeInfo, } from 'graphql-language-service'; +import type { GraphQLCache } from './GraphQLCache'; + import { GraphQLConfig, GraphQLProjectConfig } from 'graphql-config'; import type { Logger } from 'vscode-languageserver'; @@ -223,30 +224,31 @@ export class GraphQLLanguageService { return []; } const schema = await this._graphQLCache.getSchema(projectConfig.name); - const fragmentDefinitions = await this._graphQLCache.getFragmentDefinitions( - projectConfig, - ); + if (!schema) { + return []; + } + let fragmentInfo = [] as Array; + try { + const fragmentDefinitions = + await this._graphQLCache.getFragmentDefinitions(projectConfig); + fragmentInfo = Array.from(fragmentDefinitions).map( + ([, info]) => info.definition, + ); + } catch {} - const fragmentInfo = Array.from(fragmentDefinitions).map( - ([, info]) => info.definition, + return getAutocompleteSuggestions( + schema, + query, + position, + undefined, + fragmentInfo, + { + uri: filePath, + fillLeafsOnComplete: + projectConfig?.extensions?.languageService?.fillLeafsOnComplete ?? + false, + }, ); - - if (schema) { - return getAutocompleteSuggestions( - schema, - query, - position, - undefined, - fragmentInfo, - { - uri: filePath, - fillLeafsOnComplete: - projectConfig?.extensions?.languageService?.fillLeafsOnComplete ?? - false, - }, - ); - } - return []; } public async getHoverInformation( diff --git a/packages/graphql-language-service-server/src/Logger.ts b/packages/graphql-language-service-server/src/Logger.ts index ccc58defa81..85f530f1fd0 100644 --- a/packages/graphql-language-service-server/src/Logger.ts +++ b/packages/graphql-language-service-server/src/Logger.ts @@ -11,7 +11,15 @@ import { Logger as VSCodeLogger } from 'vscode-jsonrpc'; import { Connection } from 'vscode-languageserver'; export class Logger implements VSCodeLogger { - constructor(private _connection: Connection) {} + // TODO: allow specifying exact log level? + // for now this is to handle the debug setting + private logLevel: number; + constructor( + private _connection: Connection, + debug?: boolean, + ) { + this.logLevel = debug ? 1 : 0; + } error(message: string): void { this._connection.console.error(message); @@ -26,7 +34,15 @@ export class Logger implements VSCodeLogger { } log(message: string): void { - this._connection.console.log(message); + if (this.logLevel > 0) { + this._connection.console.log(message); + } + } + set level(level: number) { + this.logLevel = level; + } + get level() { + return this.logLevel; } } @@ -35,4 +51,8 @@ export class NoopLogger implements VSCodeLogger { warn() {} info() {} log() {} + set level(_level: number) {} + get level() { + return 0; + } } diff --git a/packages/graphql-language-service-server/src/MessageProcessor.ts b/packages/graphql-language-service-server/src/MessageProcessor.ts index e871ba4e340..fb81b68d07c 100644 --- a/packages/graphql-language-service-server/src/MessageProcessor.ts +++ b/packages/graphql-language-service-server/src/MessageProcessor.ts @@ -7,11 +7,9 @@ * */ -import mkdirp from 'mkdirp'; -import { readFileSync, existsSync, writeFileSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; +import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; import * as path from 'node:path'; -import glob from 'fast-glob'; import { URI } from 'vscode-uri'; import { CachedContent, @@ -22,6 +20,7 @@ import { Range, Position, IPosition, + GraphQLFileInfo, } from 'graphql-language-service'; import { GraphQLLanguageService } from './GraphQLLanguageService'; @@ -34,7 +33,6 @@ import type { DidOpenTextDocumentParams, DidChangeConfigurationParams, Diagnostic, - CompletionItem, CompletionList, CancellationToken, Hover, @@ -53,6 +51,7 @@ import type { WorkspaceSymbolParams, Connection, DidChangeConfigurationRegistrationOptions, + TextDocumentContentChangeEvent, } from 'vscode-languageserver/node'; import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load'; @@ -66,16 +65,17 @@ import { ConfigEmptyError, ConfigInvalidError, ConfigNotFoundError, - GraphQLExtensionDeclaration, LoaderNoResultError, ProjectNotFoundError, } from 'graphql-config'; import type { LoadConfigOptions } from './types'; import { DEFAULT_SUPPORTED_EXTENSIONS, + DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, SupportedExtensionsEnum, } from './constants'; import { NoopLogger, Logger } from './Logger'; +import glob from 'fast-glob'; const configDocLink = 'https://www.npmjs.com/package/graphql-language-service-server#user-content-graphql-configuration-file'; @@ -89,24 +89,21 @@ function toPosition(position: VscodePosition): IPosition { } export class MessageProcessor { - _connection: Connection; - _graphQLCache!: GraphQLCache; - _graphQLConfig: GraphQLConfig | undefined; - _languageService!: GraphQLLanguageService; - _textDocumentCache = new Map(); - _isInitialized = false; - _isGraphQLConfigMissing: boolean | null = null; - _willShutdown = false; - _logger: Logger | NoopLogger; - _extensions?: GraphQLExtensionDeclaration[]; - _parser: (text: string, uri: string) => CachedContent[]; - _tmpDir: string; - _tmpUriBase: string; - _tmpDirBase: string; - _loadConfigOptions: LoadConfigOptions; - _schemaCacheInit = false; - _rootPath: string = process.cwd(); - _settings: any; + private _connection: Connection; + private _graphQLCache!: GraphQLCache; + private _languageService!: GraphQLLanguageService; + private _textDocumentCache = new Map(); + private _isInitialized = false; + private _isGraphQLConfigMissing: boolean | null = null; + private _willShutdown = false; + private _logger: Logger | NoopLogger; + private _parser: (text: string, uri: string) => CachedContent[]; + private _tmpDir: string; + private _tmpDirBase: string; + private _loadConfigOptions: LoadConfigOptions; + private _rootPath: string = process.cwd(); + private _settings: any; + private _providedConfig?: GraphQLConfig; constructor({ logger, @@ -127,27 +124,22 @@ export class MessageProcessor { tmpDir?: string; connection: Connection; }) { + if (config) { + this._providedConfig = config; + } this._connection = connection; this._logger = logger; - this._graphQLConfig = config; this._parser = (text, uri) => { const p = parser ?? parseDocument; return p(text, uri, fileExtensions, graphqlFileExtensions, this._logger); }; this._tmpDir = tmpDir || tmpdir(); this._tmpDirBase = path.join(this._tmpDir, 'graphql-language-service'); - this._tmpUriBase = URI.file(this._tmpDirBase).toString(); // use legacy mode by default for backwards compatibility this._loadConfigOptions = { legacy: true, ...loadConfigOptions }; - if ( - loadConfigOptions.extensions && - loadConfigOptions.extensions?.length > 0 - ) { - this._extensions = loadConfigOptions.extensions; - } if (!existsSync(this._tmpDirBase)) { - void mkdirp(this._tmpDirBase); + void mkdirSync(this._tmpDirBase); } } get connection(): Connection { @@ -157,7 +149,7 @@ export class MessageProcessor { this._connection = connection; } - async handleInitializeRequest( + public async handleInitializeRequest( params: InitializeParams, _token?: CancellationToken, configDir?: string, @@ -194,9 +186,6 @@ export class MessageProcessor { 'no rootPath configured in extension or server, defaulting to cwd', ); } - if (!serverCapabilities) { - throw new Error('GraphQL Language Server is not initialized.'); - } this._logger.info( JSON.stringify({ @@ -207,8 +196,8 @@ export class MessageProcessor { return serverCapabilities; } - - async _updateGraphQLConfig() { + // TODO next: refactor (most of) this into the `GraphQLCache` class + async _initializeGraphQLCaches() { const settings = await this._connection.workspace.getConfiguration({ section: 'graphql-config', }); @@ -216,13 +205,18 @@ export class MessageProcessor { const vscodeSettings = await this._connection.workspace.getConfiguration({ section: 'vscode-graphql', }); - if (settings?.dotEnvPath) { - require('dotenv').config({ path: settings.dotEnvPath }); - } + + // TODO: eventually we will instantiate an instance of this per workspace, + // so rootDir should become that workspace's rootDir this._settings = { ...settings, ...vscodeSettings }; const rootDir = this._settings?.load?.rootDir.length ? this._settings?.load?.rootDir : this._rootPath; + if (settings?.dotEnvPath) { + require('dotenv').config({ + path: path.resolve(rootDir, settings.dotEnvPath), + }); + } this._rootPath = rootDir; this._loadConfigOptions = { ...Object.keys(this._settings?.load ?? {}).reduce((agg, key) => { @@ -234,30 +228,54 @@ export class MessageProcessor { }, this._settings.load ?? {}), rootDir, }; + try { - // reload the graphql cache - this._graphQLCache = await getGraphQLCache({ - parser: this._parser, - loadConfigOptions: this._loadConfigOptions, + // now we have the settings so we can re-build the logger + this._logger.level = this._settings?.debug === true ? 1 : 0; + // createServer() can be called with a custom config object, and + // this is a public interface that may be used by customized versions of the server + if (this._providedConfig) { + this._graphQLCache = new GraphQLCache({ + config: this._providedConfig, + logger: this._logger, + parser: this._parser, + configDir: rootDir, + }); + this._languageService = new GraphQLLanguageService( + this._graphQLCache, + this._logger, + ); + } else { + // reload the graphql cache + this._graphQLCache = await getGraphQLCache({ + parser: this._parser, + loadConfigOptions: this._loadConfigOptions, + settings: this._settings, + logger: this._logger, + }); + this._languageService = new GraphQLLanguageService( + this._graphQLCache, + this._logger, + ); + } - logger: this._logger, - }); - this._languageService = new GraphQLLanguageService( - this._graphQLCache, - this._logger, - ); - if (this._graphQLConfig || this._graphQLCache?.getGraphQLConfig) { - const config = - this._graphQLConfig ?? this._graphQLCache.getGraphQLConfig(); + const config = this._graphQLCache.getGraphQLConfig(); + if (config) { await this._cacheAllProjectFiles(config); + // TODO: per project lazy instantiation. + // we had it working before, but it seemed like it caused bugs + // which were caused by something else. + // thus. _isInitialized should be replaced with something like + // projectHasInitialized: (projectName: string) => boolean + this._isInitialized = true; + this._isGraphQLConfigMissing = false; + this._logger.info('GraphQL Language Server caches initialized'); } - this._isInitialized = true; } catch (err) { this._handleConfigError({ err }); } } - _handleConfigError({ err }: { err: unknown; uri?: string }) { - // console.log(err, typeof err); + private _handleConfigError({ err }: { err: unknown; uri?: string }) { if (err instanceof ConfigNotFoundError || err instanceof ConfigEmptyError) { // TODO: obviously this needs to become a map by workspace from uri // for workspaces support @@ -267,7 +285,7 @@ export class MessageProcessor { // this is the only case where we don't invalidate config; // TODO: per-project schema initialization status (PR is almost ready) this._logConfigError( - 'Project not found for this file - make sure that a schema is present', + 'Project not found for this file - make sure that a schema is present in the config file or for the project', ); } else if (err instanceof ConfigInvalidError) { this._isGraphQLConfigMissing = true; @@ -288,14 +306,14 @@ export class MessageProcessor { } } - _logConfigError(errorMessage: string) { + private _logConfigError(errorMessage: string) { this._logger.error( 'WARNING: graphql-config error, only highlighting is enabled:\n' + errorMessage + `\nfor more information on using 'graphql-config' with 'graphql-language-service-server', \nsee the documentation at ${configDocLink}`, ); } - async _isGraphQLConfigFile(uri: string) { + private async _isGraphQLConfigFile(uri: string) { const configMatchers = ['graphql.config', 'graphqlrc', 'graphqlconfig']; if (this._settings?.load?.fileName?.length) { configMatchers.push(this._settings.load.fileName); @@ -308,34 +326,54 @@ export class MessageProcessor { return fileMatch; } if (uri.match('package.json')?.length) { - const graphqlConfig = await import(URI.parse(uri).fsPath); - return Boolean(graphqlConfig?.graphql); + try { + const pkgConfig = await readFile(URI.parse(uri).fsPath, 'utf-8'); + return Boolean(JSON.parse(pkgConfig)?.graphql); + } catch {} } return false; } - - async handleDidOpenOrSaveNotification( - params: DidSaveTextDocumentParams | DidOpenTextDocumentParams, - ): Promise { - /** - * Initialize the LSP server when the first file is opened or saved, - * so that we can access the user settings for config rootDir, etc - */ - const isGraphQLConfigFile = await this._isGraphQLConfigFile( - params.textDocument.uri, - ); + private async _loadConfigOrSkip(uri: string) { try { - if (!this._isInitialized || !this._graphQLCache) { - // don't try to initialize again if we've already tried - // and the graphql config file or package.json entry isn't even there + const isGraphQLConfigFile = await this._isGraphQLConfigFile(uri); + + if (!this._isInitialized) { if (this._isGraphQLConfigMissing === true && !isGraphQLConfigFile) { - return null; + return true; } - // then initial call to update graphql config - await this._updateGraphQLConfig(); + // don't try to initialize again if we've already tried + // and the graphql config file or package.json entry isn't even there + await this._initializeGraphQLCaches(); + return isGraphQLConfigFile; } + // if it has initialized, but this is another config file change, then let's handle it + if (isGraphQLConfigFile) { + await this._initializeGraphQLCaches(); + } + return isGraphQLConfigFile; } catch (err) { this._logger.error(String(err)); + // return true if it's a graphql config file so we don't treat + // this as a non-config file if it is one + return true; + } + } + + public async handleDidOpenOrSaveNotification( + params: DidSaveTextDocumentParams | DidOpenTextDocumentParams, + ): Promise { + const { textDocument } = params; + const { uri } = textDocument; + + /** + * Initialize the LSP server when the first file is opened or saved, + * so that we can access the user settings for config rootDir, etc + */ + const shouldSkip = await this._loadConfigOrSkip(uri); + // if we're loading config or the config is missing or there's an error + // don't do anything else + if (shouldSkip) { + return { uri, diagnostics: [] }; } // Here, we set the workspace settings in memory, @@ -344,55 +382,42 @@ export class MessageProcessor { // We aren't able to use initialization event for this // and the config change event is after the fact. - if (!params?.textDocument) { + if (!textDocument) { throw new Error('`textDocument` argument is required.'); } - const { textDocument } = params; - const { uri } = textDocument; const diagnostics: Diagnostic[] = []; - let contents: CachedContent[] = []; - const text = 'text' in textDocument && textDocument.text; - // Create/modify the cached entry if text is provided. - // Otherwise, try searching the cache to perform diagnostics. - if (text) { - // textDocument/didSave does not pass in the text content. - // Only run the below function if text is passed in. - contents = this._parser(text, uri); - - await this._invalidateCache(textDocument, uri, contents); - } else { - if (isGraphQLConfigFile) { - this._logger.info('updating graphql config'); - await this._updateGraphQLConfig(); - return { uri, diagnostics: [] }; - } - return null; - } - if (!this._graphQLCache) { + if (!this._isInitialized) { return { uri, diagnostics }; } try { const project = this._graphQLCache.getProjectForFile(uri); - if ( - this._isInitialized && - project?.extensions?.languageService?.enableValidation !== false - ) { - await Promise.all( - contents.map(async ({ query, range }) => { - const results = await this._languageService.getDiagnostics( - query, - uri, - this._isRelayCompatMode(query), - ); - if (results && results.length > 0) { - diagnostics.push( - ...processDiagnosticsMessage(results, query, range), + if (project) { + // the disk is always valid here, so the textDocument.text isn't useful I don't think + // const text = 'text' in textDocument && textDocument.text; + // for some reason if i try to tell to not parse empty files, it breaks :shrug: + // i think this is because if the file change is empty, it doesn't get parsed + // TODO: this could be related to a bug in how we are calling didOpenOrSave in our tests + // that doesn't reflect the real runtime behavior + + const { contents } = await this._parseAndCacheFile(uri, project); + if (project?.extensions?.languageService?.enableValidation !== false) { + await Promise.all( + contents.map(async ({ documentString, range }) => { + const results = await this._languageService.getDiagnostics( + documentString, + uri, + this._isRelayCompatMode(documentString), ); - } - }), - ); + if (results && results.length > 0) { + diagnostics.push( + ...processDiagnosticsMessage(results, documentString, range), + ); + } + }), + ); + } } this._logger.log( @@ -403,14 +428,14 @@ export class MessageProcessor { fileName: uri, }), ); + return { uri, diagnostics }; } catch (err) { this._handleConfigError({ err, uri }); + return { uri, diagnostics }; } - - return { uri, diagnostics }; } - async handleDidChangeNotification( + public async handleDidChangeNotification( params: DidChangeTextDocumentParams, ): Promise { if ( @@ -431,46 +456,47 @@ export class MessageProcessor { } const { textDocument, contentChanges } = params; const { uri } = textDocument; - const project = this._graphQLCache.getProjectForFile(uri); + try { - const contentChange = contentChanges.at(-1)!; + const project = this._graphQLCache.getProjectForFile(uri); + if (!project) { + return { uri, diagnostics: [] }; + } // As `contentChanges` is an array, and we just want the // latest update to the text, grab the last entry from the array. // If it's a .js file, try parsing the contents to see if GraphQL queries // exist. If not found, delete from the cache. - const contents = this._parser(contentChange.text, uri); - // If it's a .graphql file, proceed normally and invalidate the cache. - await this._invalidateCache(textDocument, uri, contents); - - const cachedDocument = this._getCachedDocument(uri); - - if (!cachedDocument) { - return null; - } - - await this._updateFragmentDefinition(uri, contents); - await this._updateObjectTypeDefinition(uri, contents); + const { contents } = await this._parseAndCacheFile( + uri, + project, + contentChanges, + ); + // // If it's a .graphql file, proceed normally and invalidate the cache. + // await this._invalidateCache(textDocument, uri, contents); const diagnostics: Diagnostic[] = []; if (project?.extensions?.languageService?.enableValidation !== false) { // Send the diagnostics onChange as well - await Promise.all( - contents.map(async ({ query, range }) => { - const results = await this._languageService.getDiagnostics( - query, - uri, - this._isRelayCompatMode(query), - ); - if (results && results.length > 0) { - diagnostics.push( - ...processDiagnosticsMessage(results, query, range), + try { + await Promise.all( + contents.map(async ({ documentString, range }) => { + const results = await this._languageService.getDiagnostics( + documentString, + uri, + this._isRelayCompatMode(documentString), ); - } - }), - ); + if (results && results.length > 0) { + diagnostics.push( + ...processDiagnosticsMessage(results, documentString, range), + ); + } + // skip diagnostic errors, usually related to parsing incomplete fragments + }), + ); + } catch {} } this._logger.log( @@ -491,7 +517,7 @@ export class MessageProcessor { async handleDidChangeConfiguration( _params: DidChangeConfigurationParams, ): Promise { - await this._updateGraphQLConfig(); + await this._initializeGraphQLCaches(); this._logger.log( JSON.stringify({ type: 'usage', @@ -501,8 +527,8 @@ export class MessageProcessor { return {}; } - handleDidCloseNotification(params: DidCloseTextDocumentParams): void { - if (!this._isInitialized || !this._graphQLCache) { + public handleDidCloseNotification(params: DidCloseTextDocumentParams): void { + if (!this._isInitialized) { return; } // For every `textDocument/didClose` event, delete the cached entry. @@ -529,15 +555,15 @@ export class MessageProcessor { ); } - handleShutdownRequest(): void { + public handleShutdownRequest(): void { this._willShutdown = true; } - handleExitNotification(): void { + public handleExitNotification(): void { process.exit(this._willShutdown ? 0 : 1); } - validateDocumentAndPosition(params: CompletionParams): void { + private validateDocumentAndPosition(params: CompletionParams): void { if (!params?.textDocument?.uri || !params.position) { throw new Error( '`textDocument.uri` and `position` arguments are required.', @@ -545,11 +571,11 @@ export class MessageProcessor { } } - async handleCompletionRequest( + public async handleCompletionRequest( params: CompletionParams, - ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { - return []; + ): Promise { + if (!this._isInitialized) { + return { items: [], isIncomplete: false }; } this.validateDocumentAndPosition(params); @@ -563,7 +589,7 @@ export class MessageProcessor { const cachedDocument = this._getCachedDocument(textDocument.uri); if (!cachedDocument) { - return []; + return { items: [], isIncomplete: false }; } const found = cachedDocument.contents.find(content => { @@ -575,16 +601,17 @@ export class MessageProcessor { // If there is no GraphQL query in this file, return an empty result. if (!found) { - return []; + return { items: [], isIncomplete: false }; } - const { query, range } = found; + const { documentString, range } = found; if (range) { position.line -= range.start.line; } + const result = await this._languageService.getAutocompleteSuggestions( - query, + documentString, toPosition(position), textDocument.uri, ); @@ -603,8 +630,10 @@ export class MessageProcessor { return { items: result, isIncomplete: false }; } - async handleHoverRequest(params: TextDocumentPositionParams): Promise { - if (!this._isInitialized || !this._graphQLCache) { + public async handleHoverRequest( + params: TextDocumentPositionParams, + ): Promise { + if (!this._isInitialized) { return { contents: [] }; } @@ -629,13 +658,13 @@ export class MessageProcessor { return { contents: [] }; } - const { query, range } = found; + const { documentString, range } = found; if (range) { position.line -= range.start.line; } const result = await this._languageService.getHoverInformation( - query, + documentString, toPosition(position), textDocument.uri, { useMarkdown: true }, @@ -646,26 +675,40 @@ export class MessageProcessor { }; } - async handleWatchedFilesChangedNotification( - params: DidChangeWatchedFilesParams, - ): Promise | null> { - if ( - this._isGraphQLConfigMissing || - !this._isInitialized || - !this._graphQLCache - ) { - return null; + private async _parseAndCacheFile( + uri: string, + project: GraphQLProjectConfig, + changes?: TextDocumentContentChangeEvent[], + ) { + try { + // const fileText = text || (await readFile(URI.parse(uri).fsPath, 'utf-8')); + // const contents = this._parser(fileText, uri); + // const cachedDocument = this._textDocumentCache.get(uri); + // const version = cachedDocument ? cachedDocument.version++ : 0; + // await this._invalidateCache({ uri, version }, uri, contents); + // await this._updateFragmentDefinition(uri, contents); + // await this._updateObjectTypeDefinition(uri, contents, project); + + const result = await this._graphQLCache.readAndCacheFile(uri, changes); + await this._updateSchemaIfChanged(project, uri); + + if (result) { + return { contents: result.contents ?? [], version: 0 }; + } + return { contents: [], version: 0 }; + } catch { + return { contents: [], version: 0 }; } + } - return Promise.all( + public async handleWatchedFilesChangedNotification( + params: DidChangeWatchedFilesParams, + ): Promise | null> { + const resultsForChanges = Promise.all( params.changes.map(async (change: FileEvent) => { - if ( - this._isGraphQLConfigMissing || - !this._isInitialized || - !this._graphQLCache - ) { - this._logger.warn('No cache available for handleWatchedFilesChanged'); - return; + const shouldSkip = await this._loadConfigOrSkip(change.uri); + if (shouldSkip) { + return { uri: change.uri, diagnostics: [] }; } if ( change.type === FileChangeTypeKind.Created || @@ -673,75 +716,67 @@ export class MessageProcessor { ) { const { uri } = change; - const text = readFileSync(URI.parse(uri).fsPath, 'utf-8'); - const contents = this._parser(text, uri); - - await this._updateFragmentDefinition(uri, contents); - await this._updateObjectTypeDefinition(uri, contents); - try { + let diagnostics: Diagnostic[] = []; const project = this._graphQLCache.getProjectForFile(uri); if (project) { - await this._updateSchemaIfChanged(project, uri); - } - - let diagnostics: Diagnostic[] = []; - - if ( - project?.extensions?.languageService?.enableValidation !== false - ) { - diagnostics = ( - await Promise.all( - contents.map(async ({ query, range }) => { - const results = await this._languageService.getDiagnostics( - query, - uri, - this._isRelayCompatMode(query), - ); - if (results && results.length > 0) { - return processDiagnosticsMessage(results, query, range); - } - return []; - }), - ) - ).reduce((left, right) => left.concat(right), diagnostics); + // Important! Use system file uri not file path here!!!! + const { contents } = await this._parseAndCacheFile(uri, project); + console.log({ contents, uri }, 'watched'); + if ( + project?.extensions?.languageService?.enableValidation !== false + ) { + diagnostics = ( + await Promise.all( + contents.map(async ({ documentString, range }) => { + const results = + await this._languageService.getDiagnostics( + documentString, + uri, + this._isRelayCompatMode(documentString), + ); + if (results && results.length > 0) { + return processDiagnosticsMessage( + results, + documentString, + range, + ); + } + return []; + }), + ) + ).reduce((left, right) => left.concat(right), diagnostics); + } + + return { uri, diagnostics }; } - - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'workspace/didChangeWatchedFiles', - projectName: project?.name, - fileName: uri, - }), - ); - return { uri, diagnostics }; - } catch (err) { - this._handleConfigError({ err, uri }); - return { uri, diagnostics: [] }; - } + // skip diagnostics errors usually from incomplete files + } catch {} + return { uri, diagnostics: [] }; } if (change.type === FileChangeTypeKind.Deleted) { - await this._graphQLCache.updateFragmentDefinitionCache( - this._graphQLCache.getGraphQLConfig().dirpath, - change.uri, - false, - ); - await this._graphQLCache.updateObjectTypeDefinitionCache( - this._graphQLCache.getGraphQLConfig().dirpath, - change.uri, - false, - ); + const cache = await this._getDocumentCacheForFile(change.uri); + cache?.delete(change.uri); + await this._updateFragmentDefinition(change.uri, []); + await this._updateObjectTypeDefinition(change.uri, []); } }), ); + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'workspace/didChangeWatchedFiles', + files: params.changes.map(change => change.uri), + }), + ); + return resultsForChanges; } - async handleDefinitionRequest( + public async handleDefinitionRequest( params: TextDocumentPositionParams, _token?: CancellationToken, ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { + if (!this._isInitialized) { return []; } @@ -750,16 +785,13 @@ export class MessageProcessor { } const { textDocument, position } = params; const project = this._graphQLCache.getProjectForFile(textDocument.uri); - if (project) { - await this._cacheSchemaFilesForProject(project); - } const cachedDocument = this._getCachedDocument(textDocument.uri); if (!cachedDocument) { return []; } const found = cachedDocument.contents.find(content => { - const currentRange = content.range; + const currentRange = content?.range; if (currentRange?.containsPosition(toPosition(position))) { return true; } @@ -770,16 +802,16 @@ export class MessageProcessor { return []; } - const { query, range: parentRange } = found; - if (parentRange) { - position.line -= parentRange.start.line; - } + const { documentString, range: parentRange } = found; + // if (parentRange) { + // position.line -= parentRange.start.line; + // } let result = null; try { result = await this._languageService.getDefinition( - query, + documentString, toPosition(position), textDocument.uri, ); @@ -789,7 +821,7 @@ export class MessageProcessor { const inlineFragments: string[] = []; try { - visit(parse(query), { + visit(parse(documentString), { FragmentDefinition(node: FragmentDefinitionNode) { inlineFragments.push(node.name.value); }, @@ -801,12 +833,19 @@ export class MessageProcessor { const defRange = res.range as Range; if (parentRange && res.name) { - const isInline = inlineFragments.includes(res.name); + const isInline = inlineFragments?.includes(res.name); const isEmbedded = DEFAULT_SUPPORTED_EXTENSIONS.includes( - path.extname(textDocument.uri) as SupportedExtensionsEnum, + path.extname(res.path) as SupportedExtensionsEnum, ); - if (isInline && isEmbedded) { - const vOffset = parentRange.start.line; + + if (isEmbedded || isInline) { + const cachedDoc = this._getCachedDocument( + URI.parse(res.path).toString(), + ); + const vOffset = isEmbedded + ? cachedDoc?.contents[0].range?.start.line ?? 0 + : parentRange.start.line; + defRange.setStart( (defRange.start.line += vOffset), defRange.start.character, @@ -835,10 +874,10 @@ export class MessageProcessor { return formatted; } - async handleDocumentSymbolRequest( + public async handleDocumentSymbolRequest( params: DocumentSymbolParams, ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { + if (!this._isInitialized) { return []; } @@ -855,7 +894,7 @@ export class MessageProcessor { if ( this._settings.largeFileThreshold !== undefined && this._settings.largeFileThreshold < - cachedDocument.contents[0].query.length + cachedDocument.contents[0].documentString.length ) { return []; } @@ -869,7 +908,7 @@ export class MessageProcessor { ); return this._languageService.getDocumentSymbols( - cachedDocument.contents[0].query, + cachedDocument.contents[0].documentString, textDocument.uri, ); } @@ -895,14 +934,12 @@ export class MessageProcessor { // ); // } - async handleWorkspaceSymbolRequest( + public async handleWorkspaceSymbolRequest( params: WorkspaceSymbolParams, ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { + if (!this._isInitialized) { return []; } - // const config = await this._graphQLCache.getGraphQLConfig(); - // await this._cacheAllProjectFiles(config); if (params.query !== '') { const documents = this._getTextDocuments(); @@ -910,11 +947,12 @@ export class MessageProcessor { await Promise.all( documents.map(async ([uri]) => { const cachedDocument = this._getCachedDocument(uri); + if (!cachedDocument) { return []; } const docSymbols = await this._languageService.getDocumentSymbols( - cachedDocument.contents[0].query, + cachedDocument.contents[0].documentString, uri, ); symbols.push(...docSymbols); @@ -928,41 +966,54 @@ export class MessageProcessor { return []; } - _getTextDocuments() { + private _getTextDocuments() { return Array.from(this._textDocumentCache); } - async _cacheSchemaText(uri: string, text: string, version: number) { + private async _cacheSchemaText( + uri: string, + text: string, + version: number, + project?: GraphQLProjectConfig, + ) { try { const contents = this._parser(text, uri); if (contents.length > 0) { await this._invalidateCache({ version, uri }, uri, contents); - await this._updateObjectTypeDefinition(uri, contents); + await this._updateObjectTypeDefinition(uri, contents, project); } } catch (err) { this._logger.error(String(err)); } } - async _cacheSchemaFile( - _uri: UnnormalizedTypeDefPointer, + private async _cacheSchemaFile( + fileUri: UnnormalizedTypeDefPointer, project: GraphQLProjectConfig, ) { - const uri = _uri.toString(); - - const isFileUri = existsSync(uri); - let version = 1; - if (isFileUri) { - const schemaUri = URI.file(path.join(project.dirpath, uri)).toString(); - const schemaDocument = this._getCachedDocument(schemaUri); - - if (schemaDocument) { - version = schemaDocument.version++; + try { + // const parsedUri = URI.file(fileUri.toString()); + // @ts-expect-error + const matches = await glob(fileUri, { + cwd: project.dirpath, + absolute: true, + }); + const uri = matches[0]; + let version = 1; + if (uri) { + const schemaUri = URI.file(uri).toString(); + const schemaDocument = this._getCachedDocument(schemaUri); + + if (schemaDocument) { + version = schemaDocument.version++; + } + const schemaText = await readFile(uri, 'utf8'); + await this._cacheSchemaText(schemaUri, schemaText, version); } - const schemaText = readFileSync(uri, 'utf8'); - await this._cacheSchemaText(schemaUri, schemaText, version); + } catch (err) { + this._logger.error(String(err)); } } - _getTmpProjectPath( + private _getTmpProjectPath( project: GraphQLProjectConfig, prependWithProtocol = true, appendPath?: string, @@ -972,7 +1023,9 @@ export class MessageProcessor { const basePath = path.join(this._tmpDirBase, workspaceName); let projectTmpPath = path.join(basePath, 'projects', project.name); if (!existsSync(projectTmpPath)) { - void mkdirp(projectTmpPath); + mkdirSync(projectTmpPath, { + recursive: true, + }); } if (appendPath) { projectTmpPath = path.join(projectTmpPath, appendPath); @@ -982,56 +1035,8 @@ export class MessageProcessor { } return path.resolve(projectTmpPath); } - /** - * Safely attempts to cache schema files based on a glob or path - * Exits without warning in several cases because these strings can be almost - * anything! - * @param uri - * @param project - */ - async _cacheSchemaPath(uri: string, project: GraphQLProjectConfig) { - try { - const files = await glob(uri); - if (files && files.length > 0) { - await Promise.all( - files.map(uriPath => this._cacheSchemaFile(uriPath, project)), - ); - } else { - try { - await this._cacheSchemaFile(uri, project); - } catch { - // this string may be an SDL string even, how do we even evaluate this? - } - } - } catch {} - } - async _cacheObjectSchema( - pointer: { [key: string]: any }, - project: GraphQLProjectConfig, - ) { - await Promise.all( - Object.keys(pointer).map(async schemaUri => - this._cacheSchemaPath(schemaUri, project), - ), - ); - } - async _cacheArraySchema( - pointers: UnnormalizedTypeDefPointer[], - project: GraphQLProjectConfig, - ) { - await Promise.all( - pointers.map(async schemaEntry => { - if (typeof schemaEntry === 'string') { - await this._cacheSchemaPath(schemaEntry, project); - } else if (schemaEntry) { - await this._cacheObjectSchema(schemaEntry, project); - } - }), - ); - } - async _cacheSchemaFilesForProject(project: GraphQLProjectConfig) { - const schema = project?.schema; + private async _cacheSchemaFilesForProject(project: GraphQLProjectConfig) { const config = project?.extensions?.languageService; /** * By default, we look for schema definitions in SDL files @@ -1050,15 +1055,29 @@ export class MessageProcessor { const cacheSchemaFileForLookup = config?.cacheSchemaFileForLookup ?? this?._settings?.cacheSchemaFileForLookup ?? - false; - if (cacheSchemaFileForLookup) { + true; + const unwrappedSchema = this._unwrapProjectSchema(project); + const allExtensions = [ + ...DEFAULT_SUPPORTED_EXTENSIONS, + ...DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, + ]; + // only local schema lookups if all of the schema entries are local files + const sdlOnly = unwrappedSchema.every(schemaEntry => + allExtensions.some( + // local schema file URIs for lookup don't start with http, and end with an extension. + // though it isn't often used, technically schema config could include a remote .graphql file + ext => !schemaEntry.startsWith('http') && schemaEntry.endsWith(ext), + ), + ); + // if we are caching the config schema, and it isn't a .graphql file, cache it + if (cacheSchemaFileForLookup && !sdlOnly) { await this._cacheConfigSchema(project); - } else if (typeof schema === 'string') { - await this._cacheSchemaPath(schema, project); - } else if (Array.isArray(schema)) { - await this._cacheArraySchema(schema, project); - } else if (schema) { - await this._cacheObjectSchema(schema, project); + } else if (sdlOnly) { + await Promise.all( + unwrappedSchema.map(async schemaEntry => + this._cacheSchemaFile(schemaEntry, project), + ), + ); } } /** @@ -1066,7 +1085,7 @@ export class MessageProcessor { * from GraphQLCache.getSchema() * @param project {GraphQLProjectConfig} */ - async _cacheConfigSchema(project: GraphQLProjectConfig) { + private async _cacheConfigSchema(project: GraphQLProjectConfig) { try { const schema = await this._graphQLCache.getSchema(project.name); if (schema) { @@ -1087,10 +1106,10 @@ export class MessageProcessor { schemaText = `# This is an automatically generated representation of your schema.\n# Any changes to this file will be overwritten and will not be\n# reflected in the resulting GraphQL schema\n\n${schemaText}`; const cachedSchemaDoc = this._getCachedDocument(uri); - + this._graphQLCache._schemaMap.set(project.name, { schema }); if (!cachedSchemaDoc) { await writeFile(fsPath, schemaText, 'utf8'); - await this._cacheSchemaText(uri, schemaText, 1); + await this._cacheSchemaText(uri, schemaText, 0, project); } // do we have a change in the getSchema result? if so, update schema cache if (cachedSchemaDoc) { @@ -1099,6 +1118,7 @@ export class MessageProcessor { uri, schemaText, cachedSchemaDoc.version++, + project, ); } } @@ -1113,7 +1133,7 @@ export class MessageProcessor { * * @param project {GraphQLProjectConfig} */ - async _cacheDocumentFilesforProject(project: GraphQLProjectConfig) { + private async _cacheDocumentFilesforProject(project: GraphQLProjectConfig) { try { const documents = await project.getDocuments(); return Promise.all( @@ -1132,7 +1152,7 @@ export class MessageProcessor { // I would use the already existing graphql-config AST, but there are a few reasons we can't yet const contents = this._parser(document.rawSDL, uri); - if (!contents[0]?.query) { + if (!contents[0]?.documentString) { return; } await this._updateObjectTypeDefinition(uri, contents); @@ -1152,33 +1172,65 @@ export class MessageProcessor { * Caching all the document files upfront could be expensive. * @param config {GraphQLConfig} */ - async _cacheAllProjectFiles(config: GraphQLConfig) { + private async _cacheAllProjectFiles(config: GraphQLConfig) { if (config?.projects) { return Promise.all( Object.keys(config.projects).map(async projectName => { const project = config.getProject(projectName); - await this._cacheSchemaFilesForProject(project); - await this._cacheDocumentFilesforProject(project); + const cacheKey = this._graphQLCache._cacheKeyForProject(project); + const { objectTypeDefinitions, graphQLFileMap, fragmentDefinitions } = + await this._graphQLCache._buildCachesFromInputDirs( + project.dirpath, + project, + ); + + this._graphQLCache._typeDefinitionsCache.set( + cacheKey, + objectTypeDefinitions, + ); + this._graphQLCache._graphQLFileListCache.set( + cacheKey, + graphQLFileMap, + ); + this._graphQLCache._fragmentDefinitionsCache.set( + cacheKey, + fragmentDefinitions, + ); + if (!project.documents) { + this._logger.warn( + [ + `No 'documents' config found for project: ${projectName}.`, + 'Fragments and query documents cannot be detected.', + 'LSP server will only perform some partial validation and SDL features.', + ].join('\n'), + ); + } }), ); } } _isRelayCompatMode(query: string): boolean { return ( - query.includes('RelayCompat') || query.includes('react-relay/compat') + query?.includes('RelayCompat') || query?.includes('react-relay/compat') ); } - async _updateFragmentDefinition( + private async _updateFragmentDefinition( uri: Uri, contents: CachedContent[], ): Promise { - const rootDir = this._graphQLCache.getGraphQLConfig().dirpath; - - await this._graphQLCache.updateFragmentDefinition(rootDir, uri, contents); + const project = this._graphQLCache.getProjectForFile(uri); + if (project) { + const cacheKey = this._graphQLCache._cacheKeyForProject(project); + await this._graphQLCache.updateFragmentDefinition( + cacheKey, + uri, + contents, + ); + } } - async _updateSchemaIfChanged( + private async _updateSchemaIfChanged( project: GraphQLProjectConfig, uri: Uri, ): Promise { @@ -1187,13 +1239,18 @@ export class MessageProcessor { const schemaFilePath = path.resolve(project.dirpath, schema); const uriFilePath = URI.parse(uri).fsPath; if (uriFilePath === schemaFilePath) { - await this._graphQLCache.invalidateSchemaCacheForProject(project); + try { + const file = await readFile(schemaFilePath, 'utf-8'); + // only invalidate the schema cache if we can actually parse the file + // otherwise, leave the last valid one in place + parse(file, { noLocation: true }); + this._graphQLCache.invalidateSchemaCacheForProject(project); + } catch {} } }), ); } - - _unwrapProjectSchema(project: GraphQLProjectConfig): string[] { + private _unwrapProjectSchema(project: GraphQLProjectConfig): string[] { const projectSchema = project.schema; const schemas: string[] = []; @@ -1211,21 +1268,56 @@ export class MessageProcessor { schemas.push(...Object.keys(projectSchema)); } - return schemas; + return schemas.reduce((agg, schema) => { + const results = this._globIfFilePattern(schema); + return [...agg, ...results]; + }, []); + } + private _globIfFilePattern(pattern: string) { + if (pattern.includes('*')) { + try { + return glob.sync(pattern); + // URLs may contain * characters + } catch {} + } + return [pattern]; } - async _updateObjectTypeDefinition( + private async _updateObjectTypeDefinition( uri: Uri, contents: CachedContent[], + project?: GraphQLProjectConfig, ): Promise { - const rootDir = this._graphQLCache.getGraphQLConfig().dirpath; + const resolvedProject = + project ?? (await this._graphQLCache.getProjectForFile(uri)); + if (resolvedProject) { + const cacheKey = this._graphQLCache._cacheKeyForProject(resolvedProject); + await this._graphQLCache.updateObjectTypeDefinition( + cacheKey, + uri, + contents, + ); + } + } - await this._graphQLCache.updateObjectTypeDefinition(rootDir, uri, contents); + private _getDocumentCacheForFile( + uri: string, + ): Map | undefined { + const project = this._graphQLCache.getProjectForFile(uri); + if (project) { + return this._graphQLCache._graphQLFileListCache.get( + this._graphQLCache._cacheKeyForProject(project), + ); + } } - _getCachedDocument(uri: string): CachedDocumentType | null { - if (this._textDocumentCache.has(uri)) { - const cachedDocument = this._textDocumentCache.get(uri); + private _getCachedDocument(uri: string): CachedDocumentType | null { + const project = this._graphQLCache.getProjectForFile(uri); + if (project) { + const cachedDocument = this._graphQLCache._getCachedDocument( + uri, + project, + ); if (cachedDocument) { return cachedDocument; } @@ -1233,13 +1325,25 @@ export class MessageProcessor { return null; } - async _invalidateCache( + private async _invalidateCache( textDocument: VersionedTextDocumentIdentifier, uri: Uri, contents: CachedContent[], ): Promise | null> { - if (this._textDocumentCache.has(uri)) { - const cachedDocument = this._textDocumentCache.get(uri); + let documentCache = this._getDocumentCacheForFile(uri); + if (!documentCache) { + const project = await this._graphQLCache.getProjectForFile(uri); + if (!project) { + return null; + } + documentCache = new Map(); + this._graphQLCache._graphQLFileListCache.set( + this._graphQLCache._cacheKeyForProject(project), + documentCache, + ); + } + if (documentCache?.has(uri)) { + const cachedDocument = documentCache.get(uri); if ( cachedDocument && textDocument && @@ -1248,23 +1352,23 @@ export class MessageProcessor { ) { // Current server capabilities specify the full sync of the contents. // Therefore always overwrite the entire content. - return this._textDocumentCache.set(uri, { + return documentCache.set(uri, { version: textDocument.version, contents, }); } } - return this._textDocumentCache.set(uri, { + return documentCache.set(uri, { version: textDocument.version ?? 0, contents, }); } } -function processDiagnosticsMessage( +export function processDiagnosticsMessage( results: Diagnostic[], query: string, - range: RangeType | null, + range?: RangeType, ): Diagnostic[] { const queryLines = query.split('\n'); const totalLines = queryLines.length; diff --git a/packages/graphql-language-service-server/src/__tests__/GraphQLCache-test.ts b/packages/graphql-language-service-server/src/__tests__/GraphQLCache-test.ts index 54082249e3f..146d9c3cc37 100644 --- a/packages/graphql-language-service-server/src/__tests__/GraphQLCache-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/GraphQLCache-test.ts @@ -179,7 +179,7 @@ describe('GraphQLCache', () => { '});'; const contents = parseDocument(text, 'test.js'); const result = await cache.getFragmentDependenciesForAST( - parse(contents[0].query), + parse(contents[0].documentString), fragmentDefinitions, ); expect(result.length).toEqual(2); diff --git a/packages/graphql-language-service-server/src/__tests__/Logger.test.ts b/packages/graphql-language-service-server/src/__tests__/Logger.test.ts new file mode 100644 index 00000000000..82ac05fd097 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/Logger.test.ts @@ -0,0 +1,39 @@ +import { Logger } from '../Logger'; + +describe('Logger', () => { + const connection = { + console: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + log: jest.fn(), + }, + onDidChangeConfiguration: jest.fn(), + workspace: { + getConfiguration: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize with default log level, and ignore .log intentionally', () => { + const logger = new Logger(connection as any); + expect(logger).toBeDefined(); + expect(logger.logLevel).toBe(0); + logger.log('test'); + expect(connection.console.log).toHaveBeenCalledTimes(0); + }); + + it('should not change log level when settings are not passed', () => { + const logger = new Logger(connection as any, true); + expect(logger).toBeDefined(); + expect(logger.logLevel).toBe(1); + logger.log('test'); + expect(connection.console.log).toHaveBeenCalledTimes(1); + expect(logger.logLevel).toBe(1); + logger.log('test'); + expect(connection.console.log).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts deleted file mode 100644 index e2c2ecdaaf9..00000000000 --- a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts +++ /dev/null @@ -1,601 +0,0 @@ -/** - * Copyright (c) 2021 GraphQL Contributors - * All rights reserved. - * - * This source code is licensed under the license found in the - * LICENSE file in the root directory of this source tree. - * - */ -import { SymbolKind } from 'vscode-languageserver'; -import { FileChangeType } from 'vscode-languageserver-protocol'; -import { Position, Range } from 'graphql-language-service'; - -import { MessageProcessor } from '../MessageProcessor'; -import { parseDocument } from '../parseDocument'; - -jest.mock('../Logger'); - -import { GraphQLCache } from '../GraphQLCache'; - -import { loadConfig } from 'graphql-config'; - -import type { DefinitionQueryResult, Outline } from 'graphql-language-service'; - -import { NoopLogger } from '../Logger'; -import { pathToFileURL } from 'node:url'; -import mockfs from 'mock-fs'; -import { join } from 'node:path'; - -jest.mock('node:fs', () => ({ - ...jest.requireActual('fs'), - readFileSync: jest.fn(jest.requireActual('fs').readFileSync), -})); - -describe('MessageProcessor', () => { - const logger = new NoopLogger(); - const messageProcessor = new MessageProcessor({ - // @ts-ignore - connection: {}, - logger, - graphqlFileExtensions: ['graphql'], - loadConfigOptions: { rootDir: __dirname }, - }); - - const queryPathUri = pathToFileURL(`${__dirname}/__queries__`); - const textDocumentTestString = ` - { - hero(episode: NEWHOPE){ - } - } - `; - - beforeEach(async () => { - const gqlConfig = await loadConfig({ rootDir: __dirname, extensions: [] }); - // loadConfig.mockRestore(); - messageProcessor._settings = { load: {} }; - messageProcessor._graphQLCache = new GraphQLCache({ - configDir: __dirname, - config: gqlConfig, - parser: parseDocument, - logger: new NoopLogger(), - }); - messageProcessor._languageService = { - // @ts-ignore - getAutocompleteSuggestions(query, position, uri) { - return [{ label: `${query} at ${uri}` }]; - }, - // @ts-ignore - getDiagnostics(_query, _uri) { - return []; - }, - async getDocumentSymbols(_query: string, uri: string) { - return [ - { - name: 'item', - kind: SymbolKind.Field, - location: { - uri, - range: { - start: { line: 1, character: 2 }, - end: { line: 1, character: 4 }, - }, - }, - }, - ]; - }, - async getOutline(_query: string): Promise { - return { - outlineTrees: [ - { - representativeName: 'item', - kind: 'Field', - startPosition: new Position(1, 2), - endPosition: new Position(1, 4), - children: [], - }, - ], - }; - }, - async getDefinition( - _query, - position, - uri, - ): Promise { - return { - queryRange: [new Range(position, position)], - definitions: [ - { - position, - path: uri, - }, - ], - }; - }, - }; - }); - - let getConfigurationReturnValue = {}; - // @ts-ignore - messageProcessor._connection = { - // @ts-ignore - get workspace() { - return { - async getConfiguration() { - return [getConfigurationReturnValue]; - }, - }; - }, - }; - - const initialDocument = { - textDocument: { - text: textDocumentTestString, - uri: `${queryPathUri}/test.graphql`, - version: 0, - }, - }; - - messageProcessor._isInitialized = true; - - it('initializes properly and opens a file', async () => { - const { capabilities } = await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: __dirname, - }, - null, - __dirname, - ); - expect(capabilities.definitionProvider).toEqual(true); - expect(capabilities.workspaceSymbolProvider).toEqual(true); - expect(capabilities.completionProvider.resolveProvider).toEqual(true); - expect(capabilities.textDocumentSync).toEqual(1); - }); - - it('runs completion requests properly', async () => { - const uri = `${queryPathUri}/test2.graphql`; - const query = 'test'; - messageProcessor._textDocumentCache.set(uri, { - version: 0, - contents: [ - { - query, - range: new Range(new Position(0, 0), new Position(0, 0)), - }, - ], - }); - - const test = { - position: new Position(0, 0), - textDocument: { uri }, - }; - const result = await messageProcessor.handleCompletionRequest(test); - expect(result).toEqual({ - items: [{ label: `${query} at ${uri}` }], - isIncomplete: false, - }); - }); - - it('runs document symbol requests', async () => { - const uri = `${queryPathUri}/test3.graphql`; - const validQuery = ` - { - hero(episode: EMPIRE){ - ...testFragment - } - } - `; - - const newDocument = { - textDocument: { - text: validQuery, - uri, - version: 0, - }, - }; - - messageProcessor._textDocumentCache.set(uri, { - version: 0, - contents: [ - { - query: validQuery, - range: new Range(new Position(0, 0), new Position(0, 0)), - }, - ], - }); - - const test = { - textDocument: newDocument.textDocument, - }; - - const result = await messageProcessor.handleDocumentSymbolRequest(test); - - expect(result).not.toBeUndefined(); - expect(result.length).toEqual(1); - expect(result[0].name).toEqual('item'); - expect(result[0].kind).toEqual(SymbolKind.Field); - expect(result[0].location.range).toEqual({ - start: { line: 1, character: 2 }, - end: { line: 1, character: 4 }, - }); - }); - - it('properly changes the file cache with the didChange handler', async () => { - const uri = `${queryPathUri}/test.graphql`; - messageProcessor._textDocumentCache.set(uri, { - version: 1, - contents: [ - { - query: '', - range: new Range(new Position(0, 0), new Position(0, 0)), - }, - ], - }); - const textDocumentChangedString = ` - { - hero(episode: NEWHOPE){ - name - } - } - `; - - const result = await messageProcessor.handleDidChangeNotification({ - textDocument: { - // @ts-ignore - text: textDocumentTestString, - uri, - version: 1, - }, - contentChanges: [ - { text: textDocumentTestString }, - { text: textDocumentChangedString }, - ], - }); - // Query fixed, no more errors - expect(result.diagnostics.length).toEqual(0); - }); - - it('does not crash on null value returned in response to workspace configuration', async () => { - const previousConfigurationValue = getConfigurationReturnValue; - getConfigurationReturnValue = null; - await expect( - messageProcessor.handleDidChangeConfiguration(), - ).resolves.toStrictEqual({}); - getConfigurationReturnValue = previousConfigurationValue; - }); - - it('properly removes from the file cache with the didClose handler', async () => { - await messageProcessor.handleDidCloseNotification(initialDocument); - - const position = { line: 4, character: 5 }; - const params = { textDocument: initialDocument.textDocument, position }; - - // Should throw because file has been deleted from cache - try { - const result = await messageProcessor.handleCompletionRequest(params); - expect(result).toEqual(null); - } catch {} - }); - - // modified to work with jest.mock() of WatchmanClient - it('runs definition requests', async () => { - jest.setTimeout(10000); - const validQuery = ` - { - hero(episode: EMPIRE){ - ...testFragment - } - } - `; - - const newDocument = { - textDocument: { - text: validQuery, - uri: `${queryPathUri}/test3.graphql`, - version: 1, - }, - }; - messageProcessor._getCachedDocument = (_uri: string) => ({ - version: 1, - contents: [ - { - query: validQuery, - range: new Range(new Position(0, 0), new Position(20, 4)), - }, - ], - }); - - await messageProcessor.handleDidOpenOrSaveNotification(newDocument); - - const test = { - position: new Position(3, 15), - textDocument: newDocument.textDocument, - }; - - const result = await messageProcessor.handleDefinitionRequest(test); - await expect(result[0].uri).toEqual(`${queryPathUri}/test3.graphql`); - }); - - describe('handleDidOpenOrSaveNotification', () => { - const mockReadFileSync: jest.Mock = - jest.requireMock('node:fs').readFileSync; - - beforeEach(() => { - mockReadFileSync.mockReturnValue(''); - messageProcessor._updateGraphQLConfig = jest.fn(); - }); - it('updates config for standard config filename changes', async () => { - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - uri: `${pathToFileURL('.')}/.graphql.config.js`, - languageId: 'js', - version: 0, - text: '', - }, - }); - - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); - }); - - it('updates config for custom config filename changes', async () => { - const customConfigName = 'custom-config-name.yml'; - messageProcessor._settings = { load: { fileName: customConfigName } }; - - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - uri: `${pathToFileURL('.')}/${customConfigName}`, - languageId: 'js', - version: 0, - text: '', - }, - }); - - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); - }); - - it('handles config requests with no config', async () => { - messageProcessor._settings = {}; - - await messageProcessor.handleDidChangeConfiguration({ - settings: [], - }); - - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); - - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - uri: `${pathToFileURL('.')}/.graphql.config.js`, - languageId: 'js', - version: 0, - text: '', - }, - }); - - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); - }); - }); - - describe('handleWatchedFilesChangedNotification', () => { - const mockReadFileSync: jest.Mock = - jest.requireMock('node:fs').readFileSync; - - beforeEach(() => { - mockReadFileSync.mockReturnValue(''); - messageProcessor._updateGraphQLConfig = jest.fn(); - }); - - it('skips config updates for normal file changes', async () => { - await messageProcessor.handleWatchedFilesChangedNotification({ - changes: [ - { - uri: `${pathToFileURL('.')}/foo.graphql`, - type: FileChangeType.Changed, - }, - ], - }); - - expect(messageProcessor._updateGraphQLConfig).not.toHaveBeenCalled(); - }); - }); - - describe('handleWatchedFilesChangedNotification without graphql config', () => { - const mockReadFileSync: jest.Mock = - jest.requireMock('node:fs').readFileSync; - - beforeEach(() => { - mockReadFileSync.mockReturnValue(''); - messageProcessor._graphQLConfig = undefined; - messageProcessor._isGraphQLConfigMissing = true; - messageProcessor._parser = jest.fn(); - }); - - it('skips config updates for normal file changes', async () => { - await messageProcessor.handleWatchedFilesChangedNotification({ - changes: [ - { - uri: `${pathToFileURL('.')}/foo.js`, - type: FileChangeType.Changed, - }, - ], - }); - expect(messageProcessor._parser).not.toHaveBeenCalled(); - }); - }); - - describe('handleDidChangedNotification without graphql config', () => { - const mockReadFileSync: jest.Mock = - jest.requireMock('node:fs').readFileSync; - - beforeEach(() => { - mockReadFileSync.mockReturnValue(''); - messageProcessor._graphQLConfig = undefined; - messageProcessor._isGraphQLConfigMissing = true; - messageProcessor._parser = jest.fn(); - }); - - it('skips config updates for normal file changes', async () => { - await messageProcessor.handleDidChangeNotification({ - textDocument: { - uri: `${pathToFileURL('.')}/foo.js`, - version: 1, - }, - contentChanges: [{ text: 'var something' }], - }); - expect(messageProcessor._parser).not.toHaveBeenCalled(); - }); - }); -}); - -describe('MessageProcessor with no config', () => { - let messageProcessor: MessageProcessor; - const mockRoot = join('/tmp', 'test'); - let loggerSpy: jest.SpyInstance; - - const mockProcessor = (query: string, config?: string) => { - const items = { - 'query.graphql': query, - 'node_modules/parse-json': mockfs.load('node_modules/parse-json'), - }; - if (config) { - items['graphql.config.js'] = config; - } - const files: Record> = { - [mockRoot]: mockfs.directory({ - items, - }), - // node_modules: mockfs.load('node_modules'), - }; - mockfs(files); - const logger = new NoopLogger(); - loggerSpy = jest.spyOn(logger, 'error'); - messageProcessor = new MessageProcessor({ - // @ts-ignore - connection: { - // @ts-ignore - get workspace() { - return { - async getConfiguration() { - return []; - }, - }; - }, - }, - logger, - graphqlFileExtensions: ['graphql'], - loadConfigOptions: { rootDir: mockRoot }, - }); - }; - - beforeEach(() => {}); - - afterEach(() => { - mockfs.restore(); - }); - it('fails to initialize with empty config file', async () => { - mockProcessor('query { foo }', ''); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, - }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(messageProcessor._isGraphQLConfigMissing).toEqual(true); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( - expect.stringMatching( - /GraphQL Config file is not available in the provided config directory/, - ), - ); - }); - it('fails to initialize with no config file present', async () => { - mockProcessor('query { foo }'); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, - }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(messageProcessor._isGraphQLConfigMissing).toEqual(true); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(loggerSpy).toHaveBeenCalledWith( - expect.stringMatching( - /GraphQL Config file is not available in the provided config directory/, - ), - ); - }); - it('initializes when presented with a valid config later', async () => { - mockProcessor('query { foo }'); - await messageProcessor.handleInitializeRequest( - // @ts-ignore - { - rootPath: mockRoot, - }, - null, - mockRoot, - ); - await messageProcessor.handleDidOpenOrSaveNotification({ - textDocument: { - text: 'query { foo }', - uri: `${mockRoot}/query.graphql`, - version: 1, - }, - }); - expect(messageProcessor._isInitialized).toEqual(false); - expect(loggerSpy).toHaveBeenCalledTimes(1); - // todo: get mockfs working with in-test file changes - // mockfs.restore(); - // mockfs({ - // [mockRoot]: mockfs.directory({ - // mode: 0o755, - // items: { - // 'schema.graphql': - // 'type Query { foo: String }\nschema { query: Query }', - // 'graphql.config.js': mockfs.file({ - // content: 'module.exports = { schema: "schema.graphql" };', - // mode: 0o644, - // }), - // 'query.graphql': 'query { foo }', - // // 'node_modules/graphql-config/node_modules': mockfs.load( - // // 'node_modules/graphql-config/node_modules', - // // ), - // }, - // }), - // }); - // // console.log(readdirSync(`${mockRoot}`)); - // await messageProcessor.handleDidOpenOrSaveNotification({ - // textDocument: { - // text: 'module.exports = { schema: `schema.graphql` }', - // uri: `${mockRoot}/graphql.config.js`, - // version: 2, - // }, - // }); - - // expect(messageProcessor._isGraphQLConfigMissing).toEqual(false); - - // expect(loggerSpy).toHaveBeenCalledWith( - // expect.stringMatching( - // /GraphQL Config file is not available in the provided config directory/, - // ), - // ); - }); -}); diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts new file mode 100644 index 00000000000..db402abf259 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.spec.ts @@ -0,0 +1,557 @@ +import mockfs from 'mock-fs'; +import { join } from 'node:path'; +import { MockFile, MockProject } from './__utils__/MockProject'; +// import { readFileSync } from 'node:fs'; +import { FileChangeType } from 'vscode-languageserver'; +import { serializeRange } from './__utils__/utils'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { URI } from 'vscode-uri'; + +const defaultFiles = [ + ['query.graphql', 'query { bar ...B }'], + ['fragments.graphql', 'fragment B on Foo { bar }'], +] as MockFile[]; +const schemaFile: MockFile = [ + 'schema.graphql', + 'type Query { foo: Foo, test: Test }\n\ntype Foo { bar: String }\n\ntype Test { test: Foo }', +]; + +const fooTypePosition = { + start: { line: 2, character: 0 }, + end: { line: 2, character: 24 }, +}; + +const genSchemaPath = + '/tmp/graphql-language-service/test/projects/default/generated-schema.graphql'; + +// TODO: +// - reorganize into multiple files +// - potentially a high level abstraction and/or it.each() for a pathway across configs, file extensions, etc. +// this may be cumbersome with offset position assertions but possible +// if we can create consistency that doesn't limit variability +// - convert each it() into a nested describe() block (or a top level describe() in another file), and sprinkle in it() statements to replace comments +// - fix TODO comments where bugs were found that couldn't be resolved quickly (2-4hr time box) + +describe('MessageProcessor with no config', () => { + afterEach(() => { + mockfs.restore(); + }); + it('fails to initialize with empty config file', async () => { + const project = new MockProject({ + files: [...defaultFiles, ['graphql.config.json', '']], + }); + await project.init(); + + expect(project.lsp._logger.info).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledWith( + expect.stringMatching( + /GraphQL Config file is not available in the provided config directory/, + ), + ); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + }); + it('fails to initialize with no config file present', async () => { + const project = new MockProject({ + files: [...defaultFiles], + }); + await project.init(); + + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); + expect(project.lsp._logger.error).toHaveBeenCalledWith( + expect.stringMatching( + /GraphQL Config file is not available in the provided config directory/, + ), + ); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + }); + it('initializes when presented with a valid config later', async () => { + const project = new MockProject({ + files: [...defaultFiles], + }); + await project.init(); + expect(project.lsp._isInitialized).toEqual(false); + expect(project.lsp._isGraphQLConfigMissing).toEqual(true); + expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); + + project.changeFile( + 'graphql.config.json', + '{ "schema": "./schema.graphql" }', + ); + // TODO: this should work for on watched file changes as well! + await project.lsp.handleDidOpenOrSaveNotification({ + textDocument: { + uri: project.uri('graphql.config.json'), + }, + }); + expect(project.lsp._isInitialized).toEqual(true); + expect(project.lsp._isGraphQLConfigMissing).toEqual(false); + expect(project.lsp._graphQLCache).toBeDefined(); + }); +}); + +describe('the lsp', () => { + let app; + afterEach(() => { + mockfs.restore(); + }); + beforeAll(async () => { + app = await import('../../../graphiql/test/e2e-server'); + }); + afterAll(() => { + app.server.close(); + app.wsServer.close(); + }); + it('caches files and schema with .graphql file config, and the schema updates with watched file changes', async () => { + const project = new MockProject({ + files: [ + schemaFile, + [ + 'graphql.config.json', + '{ "schema": "./schema.graphql", "documents": "./**.graphql" }', + ], + ...defaultFiles, + ], + }); + const results = await project.init('query.graphql'); + expect(results.diagnostics[0].message).toEqual( + 'Cannot query field "bar" on type "Query".', + ); + expect(results.diagnostics[1].message).toEqual( + 'Fragment "B" cannot be spread here as objects of type "Query" can never be of type "Foo".', + ); + console.log( + 'schema', + project.lsp._getCachedDocument(project.uri('schema.graphql')), + ); + const initSchemaDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('schema.graphql') }, + position: { character: 19, line: 0 }, + }); + const typeCache1 = project.lsp._graphQLCache._typeDefinitionsCache; + console.log('schema1', typeCache1); + expect(initSchemaDefRequest.length).toEqual(1); + expect(initSchemaDefRequest[0].uri).toEqual(project.uri('schema.graphql')); + expect(serializeRange(initSchemaDefRequest[0].range)).toEqual( + fooTypePosition, + ); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + expect(await project.lsp._graphQLCache.getSchema('default')).toBeDefined(); + // TODO: for some reason the cache result formats the graphql query?? + // const docCache = project.lsp._graphQLCache._getDocumentCache('default'); + // expect( + // docCache.get(project.uri('query.graphql'))!.contents[0].documentString, + // ).toContain('...B'); + const schemaDefinitions = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.graphql') }, + position: { character: 16, line: 0 }, + }); + expect(schemaDefinitions[0].uri).toEqual(project.uri('schema.graphql')); + + expect(serializeRange(schemaDefinitions[0].range)).toEqual(fooTypePosition); + + // query definition request of fragment name jumps to the fragment definition + const firstQueryDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, + position: { character: 16, line: 0 }, + }); + expect(firstQueryDefRequest[0].uri).toEqual( + project.uri('fragments.graphql'), + ); + expect(serializeRange(firstQueryDefRequest[0].range)).toEqual({ + start: { + line: 0, + character: 0, + }, + end: { + line: 0, + character: 25, + }, + }); + // change the file to make the fragment invalid + project.changeFile( + 'schema.graphql', + // now Foo has a bad field, the fragment should be invalid + 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\ntype Foo { bad: Int }', + ); + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('schema.graphql'), type: FileChangeType.Changed }, + ], + }); + const typeCache = + project.lsp._graphQLCache._typeDefinitionsCache.get('/tmp/test-default'); + expect(typeCache?.get('Test')?.definition.name.value).toEqual('Test'); + + // test in-file schema defs! important! + const schemaDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('schema.graphql') }, + position: { character: 19, line: 0 }, + }); + + const fooLaterTypePosition = { + start: { line: 7, character: 0 }, + end: { line: 7, character: 21 }, + }; + expect(schemaDefRequest.length).toEqual(1); + expect(schemaDefRequest[0].uri).toEqual(project.uri('schema.graphql')); + expect(serializeRange(schemaDefRequest[0].range)).toEqual( + fooLaterTypePosition, + ); + + // change the file to make the fragment invalid + // project.changeFile( + // 'schema.graphql', + // // now Foo has a bad field, the fragment should be invalid + // 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\n\ntype Foo { bad: Int }', + // ); + // await project.lsp.handleWatchedFilesChangedNotification({ + // changes: [ + // { + // type: FileChangeType.Changed, + // uri: project.uri('schema.graphql'), + // }, + // ], + // }); + + const newSchema = + 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\n\ntype Foo { bad: Int }'; + await project.lsp.handleDidChangeNotification({ + contentChanges: [ + { + type: FileChangeType.Changed, + text: newSchema, + range: { + start: { line: 0, character: 0 }, + end: { line: newSchema.split('\n').length, character: 21 }, + }, + }, + ], + textDocument: { uri: project.uri('schema.graphql'), version: 1 }, + }); + + const schemaDefRequest2 = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('schema.graphql') }, + position: { character: 19, line: 0 }, + }); + + const fooLaterTypePosition2 = { + start: { line: 8, character: 0 }, + end: { line: 8, character: 21 }, + }; + expect(schemaDefRequest2.length).toEqual(1); + expect(schemaDefRequest2[0].uri).toEqual(project.uri('schema.graphql')); + expect(serializeRange(schemaDefRequest2[0].range)).toEqual( + fooLaterTypePosition2, + ); + + // TODO: this fragment should now be invalid + const result = await project.lsp.handleDidOpenOrSaveNotification({ + textDocument: { uri: project.uri('fragments.graphql') }, + }); + expect(result.diagnostics[0].message).toEqual( + 'Cannot query field "bar" on type "Foo". Did you mean "bad"?', + ); + const generatedFile = existsSync(join(genSchemaPath)); + // this generated file should not exist because the schema is local! + expect(generatedFile).toEqual(false); + // simulating codegen + project.changeFile( + 'fragments.graphql', + 'fragment A on Foo { bar }\n\nfragment B on Test { test }', + ); + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('fragments.graphql'), type: FileChangeType.Changed }, + ], + }); + + // TODO: this interface should maybe not be tested here but in unit tests + const fragCache = + project.lsp._graphQLCache._fragmentDefinitionsCache.get( + '/tmp/test-default', + ); + expect(fragCache?.get('A')?.definition.name.value).toEqual('A'); + expect(fragCache?.get('B')?.definition.name.value).toEqual('B'); + + // on the second request, the position has changed + const secondQueryDefRequest = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, + position: { character: 16, line: 0 }, + }); + expect(secondQueryDefRequest[0].uri).toEqual( + project.uri('fragments.graphql'), + ); + expect(serializeRange(secondQueryDefRequest[0].range)).toEqual({ + start: { + line: 2, + character: 0, + }, + end: { + line: 2, + character: 27, + }, + }); + // definitions request for fragments jumps to a different place in schema.graphql now + const schemaDefinitionsAgain = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.graphql') }, + position: { character: 16, line: 0 }, + }); + expect(schemaDefinitionsAgain[0].uri).toEqual( + project.uri('schema.graphql'), + ); + + expect(serializeRange(schemaDefinitionsAgain[0].range)).toEqual( + fooLaterTypePosition2, + ); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + }); + + it('caches files and schema with a URL config', async () => { + const project = new MockProject({ + files: [ + ['query.graphql', 'query { test { isTest, ...T } }'], + ['fragments.graphql', 'fragment T on Test {\n isTest \n}'], + [ + 'graphql.config.json', + '{ "schema": "http://localhost:3100/graphql", "documents": "./**" }', + ], + ], + }); + + const initParams = await project.init('query.graphql'); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + + expect(initParams.diagnostics).toEqual([]); + + // schema file is present and contains schema + const file = await readFile(join(genSchemaPath), { encoding: 'utf-8' }); + expect(file.split('\n').length).toBeGreaterThan(10); + expect(await project.lsp._graphQLCache.getSchema('default')).toBeDefined(); + + const changeParams = await project.lsp.handleDidChangeNotification({ + textDocument: { uri: project.uri('query.graphql'), version: 1 }, + contentChanges: [ + { + text: 'query { test { isTest, ...T or } }', + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 35 }, + }, + }, + ], + }); + expect(changeParams?.diagnostics[0].message).toEqual( + 'Cannot query field "or" on type "Test".', + ); + + // hover works + const hover = await project.lsp.handleHoverRequest({ + position: { + character: 10, + line: 0, + }, + textDocument: { uri: project.uri('query.graphql') }, + }); + expect(hover.contents).toContain('`test` field from `Test` type.'); + + // ensure that fragment definitions work + const definitions = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, + position: { character: 26, line: 0 }, + }); + expect(definitions[0].uri).toEqual(project.uri('fragments.graphql')); + expect(serializeRange(definitions[0].range)).toEqual({ + start: { + line: 0, + character: 0, + }, + end: { + line: 2, + character: 1, + }, + }); + + const typeDefinitions = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('fragments.graphql') }, + position: { character: 15, line: 0 }, + }); + + expect(typeDefinitions[0].uri).toEqual(URI.parse(genSchemaPath).toString()); + + expect(serializeRange(typeDefinitions[0].range)).toEqual({ + start: { + line: 10, + character: 0, + }, + end: { + line: 98, + character: 1, + }, + }); + + const schemaDefs = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: URI.file(genSchemaPath).toString() }, + position: { character: 20, line: 17 }, + }); + expect(schemaDefs[0].uri).toEqual(URI.parse(genSchemaPath).toString()); + // note: if the graphiql test schema changes, + // this might break, please adjust if you see a failure here + expect(serializeRange(schemaDefs[0].range)).toEqual({ + start: { + line: 100, + character: 0, + }, + end: { + line: 108, + character: 1, + }, + }); + // lets remove the fragments file + await project.deleteFile('fragments.graphql'); + // and add a fragments.ts file, watched + await project.addFile( + 'fragments.ts', + '\n\n\nexport const fragment = gql`\n\n fragment T on Test { isTest }\n`', + true, + ); + + // await project.lsp.handleWatchedFilesChangedNotification({ + // changes: [ + // { uri: project.uri('fragments.ts'), type: FileChangeType.Created }, + // ], + // }); + const defsForTs = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('query.graphql') }, + position: { character: 26, line: 0 }, + }); + // this one is really important + expect(defsForTs[0].uri).toEqual(project.uri('fragments.ts')); + expect(serializeRange(defsForTs[0].range)).toEqual({ + start: { + line: 5, + character: 2, + }, + end: { + line: 5, + character: 31, + }, + }); + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + }); + + it('caches multiple projects with files and schema with a URL config and a local schema', async () => { + const project = new MockProject({ + files: [ + [ + 'a/fragments.ts', + '\n\n\nexport const fragment = gql`\n\n fragment TestFragment on Test { isTest }\n`', + ], + [ + 'a/query.ts', + '\n\n\nexport const query = graphql`query { test { isTest ...T } }`', + ], + + [ + 'b/query.ts', + 'import graphql from "graphql"\n\n\nconst a = graphql` query example { test() { isTest ...T } }`', + ], + [ + 'b/fragments.ts', + '\n\n\nexport const fragment = gql`\n\n fragment T on Test { isTest }\n`', + ], + ['b/schema.graphql', schemaFile[1]], + [ + 'package.json', + `{ "graphql": { "projects": { + "a": { "schema": "http://localhost:3100/graphql", "documents": "./a/**" }, + "b": { "schema": "./b/schema.graphql", "documents": "./b/**" } } + } + }`, + ], + schemaFile, + ], + }); + + const initParams = await project.init('a/query.ts'); + expect(initParams.diagnostics[0].message).toEqual('Unknown fragment "T".'); + + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + expect(await project.lsp._graphQLCache.getSchema('a')).toBeDefined(); + const file = await readFile(join(genSchemaPath.replace('default', 'a')), { + encoding: 'utf-8', + }); + expect(file.split('\n').length).toBeGreaterThan(10); + // add a new typescript file with empty query to the b project + // and expect autocomplete to only show options for project b + await project.addFile( + 'b/empty.ts', + 'import gql from "graphql-tag"\ngql`query a { }`', + ); + const completion = await project.lsp.handleCompletionRequest({ + textDocument: { uri: project.uri('b/empty.ts') }, + position: { character: 13, line: 1 }, + }); + + expect(completion.items?.length).toEqual(5); + expect(completion.items.map(i => i.label)).toEqual([ + 'foo', + 'test', + '__typename', + '__schema', + '__type', + ]); + // this confirms that autocomplete respects cross-project boundaries for types. + // it performs a definition request for the foo field in Query + const schemaCompletion1 = await project.lsp.handleCompletionRequest({ + textDocument: { uri: project.uri('b/schema.graphql') }, + position: { character: 21, line: 0 }, + }); + expect(schemaCompletion1.items.map(i => i.label)).toEqual(['Foo']); + // it performs a definition request for the Foo type in Test.test + const schemaDefinition = await project.lsp.handleDefinitionRequest({ + textDocument: { uri: project.uri('b/schema.graphql') }, + position: { character: 21, line: 4 }, + }); + expect(serializeRange(schemaDefinition[0].range)).toEqual(fooTypePosition); + + // simulate a watched schema file change (codegen, etc) + project.changeFile( + 'b/schema.graphql', + schemaFile[1] + '\ntype Example1 { field: }', + ); + await project.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { uri: project.uri('b/schema.graphql'), type: FileChangeType.Changed }, + ], + }); + // TODO: repeat this with other changes to the schema file and use a + // didChange event to see if the schema updates properly as well + // await project.lsp.handleDidChangeNotification({ + // textDocument: { uri: project.uri('b/schema.graphql'), version: 1 }, + // contentChanges: [ + // { text: schemaFile[1] + '\ntype Example1 { field: }' }, + // ], + // }); + console.log(project.fileCache.get('b/schema.graphql')); + console.log(project.lsp._graphQLCache.getSchema('b')); + const schemaCompletion = await project.lsp.handleCompletionRequest({ + textDocument: { uri: project.uri('b/schema.graphql') }, + position: { character: 24, line: 5 }, + }); + // TODO: SDL completion still feels incomplete here... where is Int? + // where is self-referential Example1? + expect(schemaCompletion.items.map(i => i.label)).toEqual([ + 'Query', + 'Foo', + 'String', + 'Test', + 'Boolean', + ]); + + expect(project.lsp._logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor.test.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.test.ts new file mode 100644 index 00000000000..8276a163bb5 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor.test.ts @@ -0,0 +1,888 @@ +/** + * Copyright (c) 2021 GraphQL Contributors + * All rights reserved. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { SymbolKind } from 'vscode-languageserver'; +import { FileChangeType } from 'vscode-languageserver-protocol'; +import { Position, Range } from 'graphql-language-service'; + +import { + MessageProcessor, + processDiagnosticsMessage, +} from '../MessageProcessor'; +import { parseDocument } from '../parseDocument'; + +jest.mock('../Logger'); + +jest.setTimeout(20000); + +import { GraphQLCache } from '../GraphQLCache'; + +import { + ConfigInvalidError, + ConfigNotFoundError, + LoaderNoResultError, + ProjectNotFoundError, + loadConfig, +} from 'graphql-config'; + +import type { DefinitionQueryResult, Outline } from 'graphql-language-service'; + +import { NoopLogger } from '../Logger'; +import { pathToFileURL } from 'node:url'; +import mockfs from 'mock-fs'; +import { join } from 'node:path'; + +jest.mock('node:fs', () => ({ + ...jest.requireActual('fs'), + readFileSync: jest.fn(jest.requireActual('fs').readFileSync), +})); + +describe('MessageProcessor', () => { + const logger = new NoopLogger(); + const messageProcessor = new MessageProcessor({ + // @ts-ignore + connection: {}, + logger, + graphqlFileExtensions: ['graphql'], + loadConfigOptions: { rootDir: __dirname }, + config: null, + }); + + const queryPathUri = pathToFileURL(`${__dirname}/__queries__`); + const textDocumentTestString = ` + { + hero(episode: NEWHOPE){ + } + } + `; + let gqlConfig; + beforeEach(async () => { + gqlConfig = await loadConfig({ rootDir: __dirname, extensions: [] }); + + // loadConfig.mockRestore(); + messageProcessor._settings = { load: {} }; + messageProcessor._graphQLCache = new GraphQLCache({ + configDir: __dirname, + config: gqlConfig, + parser: parseDocument, + logger: new NoopLogger(), + }); + messageProcessor._languageService = { + // @ts-ignore + getAutocompleteSuggestions(query, position, uri) { + return [{ label: `${query} at ${uri}` }]; + }, + // @ts-ignore + getDiagnostics(_query, _uri) { + return []; + }, + async getHoverInformation(_query, position, _uri) { + return { + contents: '```graphql\nField: hero\n```', + range: new Range(position, position), + }; + }, + async getDocumentSymbols(_query: string, uri: string) { + return [ + { + name: 'item', + kind: SymbolKind.Field, + location: { + uri, + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 4 }, + }, + }, + }, + ]; + }, + async getOutline(_query: string): Promise { + return { + outlineTrees: [ + { + representativeName: 'item', + kind: 'Field', + startPosition: new Position(1, 2), + endPosition: new Position(1, 4), + children: [], + }, + ], + }; + }, + async getDefinition( + _query, + position, + uri, + ): Promise { + return { + queryRange: [new Range(position, position)], + definitions: [ + { + position, + path: uri, + }, + ], + }; + }, + }; + }); + + let getConfigurationReturnValue = {}; + // @ts-ignore + messageProcessor._connection = { + // @ts-ignore + get workspace() { + return { + async getConfiguration() { + return [getConfigurationReturnValue]; + }, + }; + }, + }; + + const initialDocument = { + textDocument: { + text: textDocumentTestString, + uri: `${queryPathUri}/test.graphql`, + version: 0, + }, + }; + + messageProcessor._isInitialized = true; + + it('initializes properly and opens a file', async () => { + const { capabilities } = await messageProcessor.handleInitializeRequest( + // @ts-ignore + { + rootPath: __dirname, + }, + null, + __dirname, + ); + expect(capabilities.definitionProvider).toEqual(true); + expect(capabilities.workspaceSymbolProvider).toEqual(true); + expect(capabilities.completionProvider.resolveProvider).toEqual(true); + expect(capabilities.textDocumentSync).toEqual(1); + }); + it('detects a config file', async () => { + const result = await messageProcessor._isGraphQLConfigFile( + 'graphql.config.js', + ); + expect(result).toEqual(true); + const falseResult = await messageProcessor._isGraphQLConfigFile( + 'graphql.js', + ); + expect(falseResult).toEqual(false); + + mockfs({ [`${__dirname}/package.json`]: '{"graphql": {}}' }); + const pkgResult = await messageProcessor._isGraphQLConfigFile( + `file://${__dirname}/package.json`, + ); + mockfs.restore(); + expect(pkgResult).toEqual(true); + + mockfs({ [`${__dirname}/package.json`]: '{ }' }); + const pkgFalseResult = await messageProcessor._isGraphQLConfigFile( + `file://${__dirname}/package.json`, + ); + mockfs.restore(); + expect(pkgFalseResult).toEqual(false); + }); + it('runs completion requests properly', async () => { + const uri = `${queryPathUri}/test2.graphql`; + const documentString = 'test'; + messageProcessor._textDocumentCache.set(uri, { + version: 0, + contents: [ + { + documentString, + range: new Range(new Position(0, 0), new Position(0, 0)), + }, + ], + }); + + const test = { + position: new Position(0, 0), + textDocument: { uri }, + }; + const result = await messageProcessor.handleCompletionRequest(test); + expect(result).toEqual({ + items: [{ label: `${documentString} at ${uri}` }], + isIncomplete: false, + }); + }); + it('runs completion requests properly with no file present', async () => { + const test = { + position: new Position(0, 0), + textDocument: { uri: `${queryPathUri}/test13.graphql` }, + }; + const result = await messageProcessor.handleCompletionRequest(test); + expect(result).toEqual({ + items: [], + isIncomplete: false, + }); + }); + it('runs completion requests properly when not initialized', async () => { + const test = { + position: new Position(0, 3), + textDocument: { uri: `${queryPathUri}/test2.graphql` }, + }; + messageProcessor._isInitialized = false; + const result = await messageProcessor.handleCompletionRequest(test); + expect(result).toEqual({ + items: [], + isIncomplete: false, + }); + }); + + it('runs document symbol requests', async () => { + messageProcessor._isInitialized = true; + const uri = `${queryPathUri}/test3.graphql`; + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + + const newDocument = { + textDocument: { + text: validQuery, + uri, + version: 0, + }, + }; + + messageProcessor._textDocumentCache.set(uri, { + version: 0, + contents: [ + { + documentString: validQuery, + range: new Range(new Position(0, 0), new Position(0, 0)), + }, + ], + }); + + const test = { + textDocument: newDocument.textDocument, + }; + + const result = await messageProcessor.handleDocumentSymbolRequest(test); + + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(1); + expect(result[0].name).toEqual('item'); + expect(result[0].kind).toEqual(SymbolKind.Field); + expect(result[0].location.range).toEqual({ + start: { line: 1, character: 2 }, + end: { line: 1, character: 4 }, + }); + }); + it('runs document symbol requests with no file present', async () => { + const test = { + textDocument: { + uri: `${queryPathUri}/test4.graphql`, + version: 0, + }, + }; + + const result = await messageProcessor.handleDocumentSymbolRequest(test); + expect(result).toEqual([]); + }); + it('runs document symbol requests when not initialized', async () => { + const test = { + textDocument: { + uri: `${queryPathUri}/test3.graphql`, + version: 0, + }, + }; + messageProcessor._isInitialized = false; + const result = await messageProcessor.handleDocumentSymbolRequest(test); + expect(result).toEqual([]); + messageProcessor._isInitialized = true; + const nextResult = await messageProcessor.handleDocumentSymbolRequest(test); + expect(nextResult[0].location.uri).toContain('test3.graphql'); + expect(nextResult[0].name).toEqual('item'); + expect(nextResult.length).toEqual(1); + }); + + it('properly changes the file cache with the didChange handler', async () => { + const uri = `${queryPathUri}/test.graphql`; + messageProcessor._textDocumentCache.set(uri, { + version: 1, + contents: [ + { + documentString: '', + range: new Range(new Position(0, 0), new Position(0, 0)), + }, + ], + }); + const textDocumentChangedString = ` + { + hero(episode: NEWHOPE){ + name + } + } + `; + + const result = await messageProcessor.handleDidChangeNotification({ + textDocument: { + // @ts-ignore + text: textDocumentTestString, + uri, + version: 1, + }, + contentChanges: [ + { text: textDocumentTestString }, + { text: textDocumentChangedString }, + ], + }); + // Query fixed, no more errors + expect(result.diagnostics.length).toEqual(0); + }); + + it('does not crash on null value returned in response to workspace configuration', async () => { + // for some reason this is needed? can't be a good thing... must have done something to cause a performance hit on + // loading config schema.. + jest.setTimeout(10000); + const previousConfigurationValue = getConfigurationReturnValue; + getConfigurationReturnValue = null; + const result = await messageProcessor.handleDidChangeConfiguration({}); + expect(result).toEqual({}); + getConfigurationReturnValue = previousConfigurationValue; + }); + + it('properly removes from the file cache with the didClose handler', async () => { + await messageProcessor.handleDidCloseNotification(initialDocument); + + const position = { line: 4, character: 5 }; + const params = { textDocument: initialDocument.textDocument, position }; + + // Should throw because file has been deleted from cache + try { + const result = await messageProcessor.handleCompletionRequest(params); + expect(result).toEqual(null); + } catch {} + }); + + // modified to work with jest.mock() of WatchmanClient + it('runs definition requests', async () => { + jest.setTimeout(10000); + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + + const newDocument = { + textDocument: { + text: validQuery, + uri: `${queryPathUri}/test3.graphql`, + version: 1, + }, + }; + messageProcessor._getCachedDocument = (_uri: string) => ({ + version: 1, + contents: [ + { + documentString: validQuery, + range: new Range(new Position(0, 0), new Position(20, 4)), + }, + ], + }); + + await messageProcessor.handleDidOpenOrSaveNotification(newDocument); + + const test = { + position: new Position(3, 15), + textDocument: newDocument.textDocument, + }; + + const result = await messageProcessor.handleDefinitionRequest(test); + await expect(result[0].uri).toEqual(`${queryPathUri}/test3.graphql`); + }); + it('runs hover requests', async () => { + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + + const newDocument = { + textDocument: { + text: validQuery, + uri: `${queryPathUri}/test4.graphql`, + version: 1, + }, + }; + messageProcessor._getCachedDocument = (_uri: string) => ({ + version: 1, + contents: [ + { + documentString: validQuery, + range: new Range(new Position(0, 0), new Position(20, 4)), + }, + ], + }); + + await messageProcessor.handleDidOpenOrSaveNotification(newDocument); + + const test = { + position: new Position(3, 15), + textDocument: newDocument.textDocument, + }; + + const result = await messageProcessor.handleHoverRequest(test); + expect(JSON.stringify(result.contents)).toEqual( + JSON.stringify({ + contents: '```graphql\nField: hero\n```', + range: new Range(new Position(3, 15), new Position(3, 15)), + }), + ); + }); + it('runs hover request with no file present', async () => { + const test = { + position: new Position(3, 15), + textDocument: { + uri: `${queryPathUri}/test5.graphql`, + version: 1, + }, + }; + messageProcessor._getCachedDocument = (_uri: string) => null; + + const result = await messageProcessor.handleHoverRequest(test); + expect(result).toEqual({ contents: [] }); + }); + it('handles provided config', async () => { + const msgProcessor = new MessageProcessor({ + // @ts-ignore + connection: { + workspace: { + getConfiguration() { + return {}; + }, + }, + }, + logger, + graphqlFileExtensions: ['graphql'], + loadConfigOptions: { rootDir: __dirname }, + config: gqlConfig, + }); + expect(msgProcessor._providedConfig).toBeTruthy(); + await msgProcessor.handleInitializeRequest( + // @ts-ignore + { + rootPath: __dirname, + }, + null, + __dirname, + ); + await msgProcessor.handleDidChangeConfiguration({ + settings: {}, + }); + expect(msgProcessor._graphQLCache).toBeTruthy(); + }); + + it('runs workspace symbol requests', async () => { + const msgProcessor = new MessageProcessor({ + // @ts-ignore + connection: {}, + logger, + graphqlFileExtensions: ['graphql'], + loadConfigOptions: { rootDir: __dirname }, + }); + await msgProcessor.handleInitializeRequest( + // @ts-ignore + { + rootPath: __dirname, + }, + null, + __dirname, + ); + const uri = `${queryPathUri}/test6.graphql`; + const docUri = `${queryPathUri}/test7.graphql`; + const validQuery = ` + { + hero(episode: EMPIRE){ + ...testFragment + } + } + `; + const validDocument = ` + fragment testFragment on Character { + name + }`; + msgProcessor._graphQLCache = new GraphQLCache({ + configDir: __dirname, + config: await loadConfig({ rootDir: __dirname }), + parser: parseDocument, + logger: new NoopLogger(), + }); + msgProcessor._languageService = { + getDocumentSymbols: async () => [ + { + name: 'testFragment', + kind: SymbolKind.Field, + location: { + uri, + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 4 }, + }, + }, + }, + ], + }; + msgProcessor._isInitialized = true; + msgProcessor._textDocumentCache.set(uri, { + version: 0, + contents: [ + { + query: validQuery, + range: new Range(new Position(0, 0), new Position(6, 0)), + }, + ], + }); + + await msgProcessor._graphQLCache.updateFragmentDefinition( + __dirname, + docUri, + [ + { + query: validDocument, + range: new Range(new Position(0, 0), new Position(4, 0)), + }, + ], + ); + + const test = { + query: 'testFragment', + }; + + const result = await msgProcessor.handleWorkspaceSymbolRequest(test); + expect(result).not.toBeUndefined(); + expect(result.length).toEqual(1); + expect(result[0].name).toEqual('testFragment'); + expect(result[0].kind).toEqual(SymbolKind.Field); + expect(result[0].location.range).toEqual({ + start: { line: 1, character: 2 }, + end: { line: 1, character: 4 }, + }); + }); + + describe('_loadConfigOrSkip', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(''); + messageProcessor._initializeGraphQLCaches = jest.fn(); + }); + + it('loads config if not initialized', async () => { + messageProcessor._isInitialized = false; + + const result = await messageProcessor._loadConfigOrSkip( + `${pathToFileURL('.')}/graphql.config.js`, + ); + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalledTimes( + 1, + ); + // we want to return true here to skip further processing, because it's just a config file change + expect(result).toEqual(true); + }); + + it('loads config if a file change occurs and the server is not initialized', async () => { + messageProcessor._isInitialized = false; + + const result = await messageProcessor._loadConfigOrSkip( + `${pathToFileURL('.')}/file.ts`, + ); + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalledTimes( + 1, + ); + // here we have a non-config file, so we don't want to skip, because we need to run diagnostics etc + expect(result).toEqual(false); + }); + it('config file change updates server config even if the server is already initialized', async () => { + messageProcessor._isInitialized = true; + const result = await messageProcessor._loadConfigOrSkip( + `${pathToFileURL('.')}/graphql.config.ts`, + ); + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalledTimes( + 1, + ); + expect(result).toEqual(true); + }); + it('skips if the server is already initialized', async () => { + messageProcessor._isInitialized = true; + const result = await messageProcessor._loadConfigOrSkip( + `${pathToFileURL('.')}/myFile.ts`, + ); + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(result).toEqual(false); + }); + }); + + describe('handleDidOpenOrSaveNotification', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(''); + messageProcessor._initializeGraphQLCaches = jest.fn(); + messageProcessor._loadConfigOrSkip = jest.fn(); + }); + it('updates config for standard config filename changes', async () => { + await messageProcessor.handleDidOpenOrSaveNotification({ + textDocument: { + uri: `${pathToFileURL('.')}/.graphql.config.js`, + languageId: 'js', + version: 0, + text: '', + }, + }); + expect(messageProcessor._loadConfigOrSkip).toHaveBeenCalled(); + }); + + it('updates config for custom config filename changes', async () => { + const customConfigName = 'custom-config-name.yml'; + messageProcessor._settings = { load: { fileName: customConfigName } }; + + await messageProcessor.handleDidOpenOrSaveNotification({ + textDocument: { + uri: `${pathToFileURL('.')}/${customConfigName}`, + languageId: 'js', + version: 0, + text: '', + }, + }); + + expect(messageProcessor._loadConfigOrSkip).toHaveBeenCalledWith( + expect.stringContaining(customConfigName), + ); + }); + + it('handles config requests with no config', async () => { + messageProcessor._settings = {}; + + await messageProcessor.handleDidChangeConfiguration({ + settings: [], + }); + + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalled(); + + await messageProcessor.handleDidOpenOrSaveNotification({ + textDocument: { + uri: `${pathToFileURL('.')}/.graphql.config.js`, + languageId: 'js', + version: 0, + text: '', + }, + }); + + expect(messageProcessor._initializeGraphQLCaches).toHaveBeenCalled(); + }); + }); + + describe('_handleConfigErrors', () => { + it('handles missing config errors', async () => { + messageProcessor._handleConfigError({ + err: new ConfigNotFoundError('test missing-config'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('test missing-config'), + ); + }); + it('handles missing project errors', async () => { + messageProcessor._handleConfigError({ + err: new ProjectNotFoundError('test missing-project'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Project not found for this file'), + ); + }); + it('handles invalid config errors', async () => { + messageProcessor._handleConfigError({ + err: new ConfigInvalidError('test invalid error'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid configuration'), + ); + }); + it('handles empty loader result errors', async () => { + messageProcessor._handleConfigError({ + err: new LoaderNoResultError('test loader-error'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('test loader-error'), + ); + }); + it('handles generic errors', async () => { + messageProcessor._handleConfigError({ + err: new Error('test loader-error'), + uri: 'test', + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('test loader-error'), + ); + }); + }); + describe('handleWatchedFilesChangedNotification', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(' query { id }'); + messageProcessor._initializeGraphQLCaches = jest.fn(); + messageProcessor._updateFragmentDefinition = jest.fn(); + messageProcessor._isGraphQLConfigMissing = false; + messageProcessor._isInitialized = true; + }); + + it('skips config updates for normal file changes', async () => { + await messageProcessor.handleWatchedFilesChangedNotification({ + changes: [ + { + uri: `${pathToFileURL( + join(__dirname, '__queries__'), + )}/test.graphql`, + type: FileChangeType.Changed, + }, + ], + }); + + expect(messageProcessor._initializeGraphQLCaches).not.toHaveBeenCalled(); + expect(messageProcessor._updateFragmentDefinition).toHaveBeenCalled(); + }); + }); + + describe('handleWatchedFilesChangedNotification without graphql config', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(''); + messageProcessor._isGraphQLConfigMissing = true; + messageProcessor._parser = jest.fn(); + }); + + it('skips config updates for normal file changes', async () => { + await messageProcessor.handleWatchedFilesChangedNotification({ + changes: [ + { + uri: `${pathToFileURL('.')}/foo.js`, + type: FileChangeType.Changed, + }, + ], + }); + expect(messageProcessor._parser).not.toHaveBeenCalled(); + }); + }); + + describe('handleDidChangedNotification without graphql config', () => { + const mockReadFileSync: jest.Mock = + jest.requireMock('node:fs').readFileSync; + + beforeEach(() => { + mockReadFileSync.mockReturnValue(''); + messageProcessor._isGraphQLConfigMissing = true; + messageProcessor._parser = jest.fn(); + }); + + it('skips config updates for normal file changes', async () => { + await messageProcessor.handleDidChangeNotification({ + textDocument: { + uri: `${pathToFileURL('.')}/foo.js`, + version: 1, + }, + contentChanges: [{ text: 'var something' }], + }); + expect(messageProcessor._parser).not.toHaveBeenCalled(); + }); + }); +}); + +describe('processDiagnosticsMessage', () => { + it('processes diagnostics messages', () => { + const query = 'query { foo }'; + const inputRange = new Range(new Position(1, 1), new Position(1, 1)); + + const diagnostics = processDiagnosticsMessage( + [ + { + severity: 1, + message: 'test', + source: 'GraphQL: Validation', + range: inputRange, + }, + ], + query, + inputRange, + ); + + expect(JSON.stringify(diagnostics)).toEqual( + JSON.stringify([ + { + severity: 1, + message: 'test', + source: 'GraphQL: Validation', + range: new Range(new Position(2, 1), new Position(2, 1)), + }, + ]), + ); + }); + it('processes diagnostics messages with null range', () => { + const query = 'query { foo }'; + const inputRange = new Range(new Position(1, 1), new Position(1, 1)); + + const diagnostics = processDiagnosticsMessage( + [ + { + severity: 1, + message: 'test', + source: 'GraphQL: Validation', + range: inputRange, + }, + ], + query, + null, + ); + + expect(JSON.stringify(diagnostics)).toEqual( + JSON.stringify([ + { + severity: 1, + message: 'test', + source: 'GraphQL: Validation', + range: inputRange, + }, + ]), + ); + }); +}); diff --git a/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts b/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts new file mode 100644 index 00000000000..c9a86532c32 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/__utils__/MockProject.ts @@ -0,0 +1,191 @@ +import mockfs from 'mock-fs'; +import { MessageProcessor } from '../../MessageProcessor'; +import { Logger as VSCodeLogger } from 'vscode-jsonrpc'; +import { URI } from 'vscode-uri'; +import { FileChangeType } from 'vscode-languageserver'; +import { FileChangeTypeKind } from 'graphql-language-service'; + +export type MockFile = [filename: string, text: string]; + +export class MockLogger implements VSCodeLogger { + error = jest.fn(); + warn = jest.fn(); + info = jest.fn(); + log = jest.fn(); +} + +// when using mockfs with cosmic-config, a dynamic inline +// require of parse-json creates the necessity for loading in the actual +// modules to the mocked filesystem +const modules = [ + 'parse-json', + 'error-ex', + 'is-arrayish', + 'json-parse-even-better-errors', + 'lines-and-columns', + '@babel/code-frame', + '@babel/highlight', + // these i think are just required by jest when you console log from a test + 'jest-message-util', + 'stack-utils', + 'pretty-format', + 'ansi-regex', + 'js-tokens', + 'escape-string-regexp', + 'jest-worker', +]; +const defaultMocks = modules.reduce((acc, module) => { + acc[`node_modules/${module}`] = mockfs.load(`node_modules/${module}`); + return acc; +}, {}); + +type File = [filename: string, text: string]; +type Files = File[]; + +export class MockProject { + private root: string; + private fileCache: Map; + private messageProcessor: MessageProcessor; + constructor({ + files = [], + root = '/tmp/test', + settings, + }: { + files: Files; + root?: string; + settings?: [name: string, vale: any][]; + }) { + this.root = root; + this.fileCache = new Map(files); + + this.mockFiles(); + this.messageProcessor = new MessageProcessor({ + connection: { + get workspace() { + return { + async getConfiguration() { + return settings; + }, + }; + }, + }, + logger: new MockLogger(), + loadConfigOptions: { rootDir: root }, + }); + } + + public async init(filename?: string, fileText?: string) { + await this.lsp.handleInitializeRequest({ + rootPath: this.root, + rootUri: this.root, + capabilities: {}, + processId: 200, + workspaceFolders: null, + }); + return this.lsp.handleDidOpenOrSaveNotification({ + textDocument: { + uri: this.uri(filename || 'query.graphql'), + version: 1, + text: + this.fileCache.get('query.graphql') || + (filename && this.fileCache.get(filename)) || + fileText, + }, + }); + } + private mockFiles() { + const mockFiles = { ...defaultMocks }; + Array.from(this.fileCache).map(([filename, text]) => { + mockFiles[this.filePath(filename)] = text; + }); + mockfs(mockFiles); + } + public filePath(filename: string) { + return `${this.root}/${filename}`; + } + public uri(filename: string) { + return URI.file(this.filePath(filename)).toString(); + } + changeFile(filename: string, text: string) { + this.fileCache.set(filename, text); + this.mockFiles(); + } + async addFile(filename: string, text: string, watched = false) { + this.fileCache.set(filename, text); + this.mockFiles(); + if (watched) { + await this.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { + uri: this.uri(filename), + type: FileChangeTypeKind.Created, + }, + ], + }); + } + await this.lsp.handleDidChangeNotification({ + contentChanges: [ + { + type: FileChangeTypeKind.Created, + text, + }, + ], + textDocument: { + uri: this.uri(filename), + version: 2, + }, + }); + } + async changeWatchedFile(filename: string, text: string) { + this.changeFile(filename, text); + await this.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { + uri: this.uri(filename), + type: FileChangeType.Changed, + }, + ], + }); + } + async saveOpenFile(filename: string, text: string) { + this.changeFile(filename, text); + await this.lsp.handleDidOpenOrSaveNotification({ + textDocument: { + uri: this.uri(filename), + version: 2, + text, + }, + }); + } + async addWatchedFile(filename: string, text: string) { + this.changeFile(filename, text); + await this.lsp.handleDidChangeNotification({ + contentChanges: [ + { + type: FileChangeTypeKind.Created, + text, + }, + ], + textDocument: { + uri: this.uri(filename), + version: 2, + }, + }); + } + async deleteFile(filename: string) { + mockfs.restore(); + this.fileCache.delete(filename); + this.mockFiles(); + await this.lsp.handleWatchedFilesChangedNotification({ + changes: [ + { + type: FileChangeType.Deleted, + uri: this.uri(filename), + }, + ], + }); + } + get lsp() { + return this.messageProcessor; + } +} diff --git a/packages/graphql-language-service-server/src/__tests__/__utils__/runServer.js b/packages/graphql-language-service-server/src/__tests__/__utils__/runServer.js new file mode 100644 index 00000000000..0e328a55450 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/__utils__/runServer.js @@ -0,0 +1 @@ +exports.default = require('../../../../graphiql/test/e2e-server.js'); diff --git a/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts b/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts new file mode 100644 index 00000000000..4ad1eff2c26 --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/__utils__/utils.ts @@ -0,0 +1,4 @@ +import { Range } from 'vscode-languageserver'; + +export const serializeRange = (range: Range) => + JSON.parse(JSON.stringify(range)); diff --git a/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts b/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts index 6bb5c1062bf..347d517b955 100644 --- a/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/findGraphQLTags-test.ts @@ -529,4 +529,36 @@ export function Example(arg: string) {}`; const contents = findGraphQLTags(text, '.svelte'); expect(contents.length).toEqual(1); }); + it('handles full astro example', () => { + const text = ` + --- + const gql = String.raw; + const response = await fetch("https://swapi-graphql.netlify.app/.netlify/functions/index", + { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ + query: gql\` + query getFilm ($id:ID!) { + film(id: $id) { + title + releaseDate + } + } + \`, + variables: { + id: "XM6MQ==", + }, + }), + }); + + const json = await response.json(); + const { film } = json.data; + --- +

Fetching information about Star Wars: A New Hope

+

Title: {film.title}

+

Year: {film.releaseDate}

`; + const contents = findGraphQLTags(text, '.astro'); + expect(contents.length).toEqual(1); + }); }); diff --git a/packages/graphql-language-service-server/src/__tests__/parseDocument-test.ts b/packages/graphql-language-service-server/src/__tests__/parseDocument-test.ts index 0020f76d47e..07c3e426b84 100644 --- a/packages/graphql-language-service-server/src/__tests__/parseDocument-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/parseDocument-test.ts @@ -21,7 +21,7 @@ describe('parseDocument', () => { export function Example(arg: string) {}`; const contents = parseDocument(text, 'test.js'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { value @@ -49,7 +49,7 @@ describe('parseDocument', () => { export function Example(arg: string) {}`; const contents = parseDocument(text, 'test.js'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { @@ -77,7 +77,7 @@ describe('parseDocument', () => { export function Example(arg: string) {}`; const contents = parseDocument(text, 'test.ts'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { value @@ -109,7 +109,7 @@ describe('parseDocument', () => { }`; const contents = parseDocument(text, 'test.tsx'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { value @@ -142,7 +142,7 @@ describe('parseDocument', () => { const contents = parseDocument(text, 'test.tsx'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { value @@ -175,7 +175,7 @@ describe('parseDocument', () => { }`; const contents = parseDocument(text, 'test.tsx'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { value @@ -210,7 +210,7 @@ describe('parseDocument', () => { }`; const contents = parseDocument(text, 'test.tsx'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { value @@ -241,7 +241,7 @@ describe('parseDocument', () => { export function Example(arg: string) {}`; const contents = parseDocument(text, 'test.js'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { value @@ -270,7 +270,7 @@ describe('parseDocument', () => { export function Example(arg: string) {}`; const contents = parseDocument(text, 'test.ts'); - expect(contents[0].query).toEqual(`#graphql + expect(contents[0].documentString).toEqual(`#graphql query Test { test { value @@ -299,7 +299,7 @@ describe('parseDocument', () => { export function Example(arg: string) {}`; const contents = parseDocument(text, 'test.ts'); - expect(contents[0].query).toEqual(` + expect(contents[0].documentString).toEqual(` query Test { test { value diff --git a/packages/graphql-language-service-server/src/__tests__/startServer.spec.ts b/packages/graphql-language-service-server/src/__tests__/startServer.spec.ts new file mode 100644 index 00000000000..dbf4a496f7b --- /dev/null +++ b/packages/graphql-language-service-server/src/__tests__/startServer.spec.ts @@ -0,0 +1,9 @@ +import startServer from '../startServer'; + +describe('startServer', () => { + it('should start the server', async () => { + await startServer({}); + // if the server starts, we're good + expect(true).toBe(true); + }); +}); diff --git a/packages/graphql-language-service-server/src/parseDocument.ts b/packages/graphql-language-service-server/src/parseDocument.ts index 6facdebf18e..c18c0228269 100644 --- a/packages/graphql-language-service-server/src/parseDocument.ts +++ b/packages/graphql-language-service-server/src/parseDocument.ts @@ -10,6 +10,7 @@ import { SupportedExtensionsEnum, } from './constants'; import { NoopLogger } from './Logger'; +import { parse } from 'graphql'; /** * Helper functions to perform requested services from client/server. @@ -36,7 +37,10 @@ export function parseDocument( if (fileExtensions.includes(ext)) { const templates = findGraphQLTags(text, ext, uri, logger); - return templates.map(({ template, range }) => ({ query: template, range })); + return templates.map(({ template, range }) => ({ + documentString: template, + range, + })); } if (graphQLFileExtensions.includes(ext)) { const query = text; @@ -45,7 +49,7 @@ export function parseDocument( new Position(0, 0), new Position(lines.length - 1, lines.at(-1)!.length - 1), ); - return [{ query, range }]; + return [{ documentString: query, range }]; } return []; } diff --git a/packages/graphql-language-service-server/src/parsers/astro.ts b/packages/graphql-language-service-server/src/parsers/astro.ts index 0b870fdaa30..d0559fabb8b 100644 --- a/packages/graphql-language-service-server/src/parsers/astro.ts +++ b/packages/graphql-language-service-server/src/parsers/astro.ts @@ -44,11 +44,11 @@ function parseAstro(source: string): ParseAstroResult { export const astroParser: SourceParser = (text, uri, logger) => { const parseAstroResult = parseAstro(text); if (parseAstroResult.type === 'error') { - logger.error( + logger.info( `Could not parse the astro file at ${uri} to extract the graphql tags:`, ); for (const error of parseAstroResult.errors) { - logger.error(String(error)); + logger.info(String(error)); } return null; } diff --git a/packages/graphql-language-service-server/src/parsers/babel.ts b/packages/graphql-language-service-server/src/parsers/babel.ts index aa2c37bd33a..4216c11a50e 100644 --- a/packages/graphql-language-service-server/src/parsers/babel.ts +++ b/packages/graphql-language-service-server/src/parsers/babel.ts @@ -15,10 +15,10 @@ export const ecmaParser: SourceParser = (text, uri, logger) => { try { return { asts: [babelParser(text, ['flow', 'flowComments'])] }; } catch (error) { - logger.error( + logger.info( `Could not parse the JavaScript file at ${uri} to extract the graphql tags:`, ); - logger.error(String(error)); + logger.info(String(error)); return null; } }; @@ -27,10 +27,10 @@ export const tsParser: SourceParser = (text, uri, logger) => { try { return { asts: [babelParser(text, ['typescript'])] }; } catch (error) { - logger.error( + logger.info( `Could not parse the TypeScript file at ${uri} to extract the graphql tags:`, ); - logger.error(String(error)); + logger.info(String(error)); return null; } }; diff --git a/packages/graphql-language-service-server/src/parsers/svelte.ts b/packages/graphql-language-service-server/src/parsers/svelte.ts index f19178b6239..e838271ff29 100644 --- a/packages/graphql-language-service-server/src/parsers/svelte.ts +++ b/packages/graphql-language-service-server/src/parsers/svelte.ts @@ -36,10 +36,10 @@ export const svelteParser: SourceParser = (text, uri, logger) => { rangeMapper, }; } catch (error) { - logger.error( + logger.info( `Could not parse the Svelte file at ${uri} to extract the graphql tags:`, ); - logger.error(String(error)); + logger.info(String(error)); return null; } }; diff --git a/packages/graphql-language-service-server/src/parsers/vue.ts b/packages/graphql-language-service-server/src/parsers/vue.ts index cdcb30de263..a1a80be2d52 100644 --- a/packages/graphql-language-service-server/src/parsers/vue.ts +++ b/packages/graphql-language-service-server/src/parsers/vue.ts @@ -49,11 +49,11 @@ export const vueParser: SourceParser = (text, uri, logger) => { const asts = []; const parseVueSFCResult = parseVueSFC(text); if (parseVueSFCResult.type === 'error') { - logger.error( + logger.info( `Could not parse the vue file at ${uri} to extract the graphql tags:`, ); for (const error of parseVueSFCResult.errors) { - logger.error(String(error)); + logger.info(String(error)); } return null; } diff --git a/packages/graphql-language-service-server/src/startServer.ts b/packages/graphql-language-service-server/src/startServer.ts index 69924e2ac98..e22c3a8df79 100644 --- a/packages/graphql-language-service-server/src/startServer.ts +++ b/packages/graphql-language-service-server/src/startServer.ts @@ -102,6 +102,14 @@ export interface ServerOptions { * the temporary directory that the server writes to for logs and caching schema */ tmpDir?: string; + + /** + * debug mode + * + * same as with the client reference implementation, the debug setting controls logging output + * this allows all logger.info() messages to come through. by default, the highest level is warn + */ + debug?: true; } /** @@ -217,7 +225,7 @@ async function initializeHandlers({ options, }: InitializerParams): Promise { const connection = createConnection(reader, writer); - const logger = new Logger(connection); + const logger = new Logger(connection, options.debug); try { await addHandlers({ connection, logger, ...options }); diff --git a/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions-test.ts b/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions-test.ts index f7daabfe2f5..3efe44c0422 100644 --- a/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions-test.ts +++ b/packages/graphql-language-service/src/interface/__tests__/getAutocompleteSuggestions-test.ts @@ -607,6 +607,39 @@ describe('getAutocompleteSuggestions', () => { { label: 'TestType' }, { label: 'TestUnion' }, ])); + // TODO: shouldn't TestType and TestUnion be available here? + it('provides correct filtered suggestions on object fields in regular SDL files', () => + expect( + testSuggestions('type Type {\n aField: s', new Position(0, 23), [], { + uri: 'schema.graphql', + }), + ).toEqual([ + { label: 'Episode' }, + { label: 'String' }, + { label: 'TestInterface' }, + { label: 'TestType' }, + { label: 'TestUnion' }, + ])); + it('provides correct unfiltered suggestions on object fields in regular SDL files', () => + expect( + testSuggestions('type Type {\n aField: ', new Position(0, 22), [], { + uri: 'schema.graphql', + }), + ).toEqual([ + { label: 'AnotherInterface' }, + { label: 'Boolean' }, + { label: 'Character' }, + { label: 'Droid' }, + { label: 'Episode' }, + { label: 'Human' }, + { label: 'Int' }, + // TODO: maybe filter out types attached to top level schema? + { label: 'Query' }, + { label: 'String' }, + { label: 'TestInterface' }, + { label: 'TestType' }, + { label: 'TestUnion' }, + ])); it('provides correct suggestions on object fields that are arrays', () => expect( testSuggestions('type Type {\n aField: []', new Position(0, 25), [], { @@ -626,6 +659,25 @@ describe('getAutocompleteSuggestions', () => { { label: 'TestType' }, { label: 'TestUnion' }, ])); + it('provides correct suggestions on object fields that are arrays in SDL context', () => + expect( + testSuggestions('type Type {\n aField: []', new Position(0, 25), [], { + uri: 'schema.graphql', + }), + ).toEqual([ + { label: 'AnotherInterface' }, + { label: 'Boolean' }, + { label: 'Character' }, + { label: 'Droid' }, + { label: 'Episode' }, + { label: 'Human' }, + { label: 'Int' }, + { label: 'Query' }, + { label: 'String' }, + { label: 'TestInterface' }, + { label: 'TestType' }, + { label: 'TestUnion' }, + ])); it('provides correct suggestions on input object fields', () => expect( testSuggestions('input Type {\n aField: s', new Position(0, 23), [], { diff --git a/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts b/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts index 5c01896a22d..e5b5a13c8f1 100644 --- a/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts +++ b/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts @@ -399,34 +399,27 @@ export function getAutocompleteSuggestions( const unwrappedState = unwrapType(state); - if ( - (mode === GraphQLDocumentMode.TYPE_SYSTEM && - !unwrappedState.needsAdvance && - kind === RuleKinds.NAMED_TYPE) || - kind === RuleKinds.LIST_TYPE - ) { - if (unwrappedState.kind === RuleKinds.FIELD_DEF) { - return hintList( - token, - Object.values(schema.getTypeMap()) - .filter(type => isOutputType(type) && !type.name.startsWith('__')) - .map(type => ({ - label: type.name, - kind: CompletionItemKind.Function, - })), - ); - } - if (unwrappedState.kind === RuleKinds.INPUT_VALUE_DEF) { - return hintList( - token, - Object.values(schema.getTypeMap()) - .filter(type => isInputType(type) && !type.name.startsWith('__')) - .map(type => ({ - label: type.name, - kind: CompletionItemKind.Function, - })), - ); - } + if (unwrappedState.kind === RuleKinds.FIELD_DEF) { + return hintList( + token, + Object.values(schema.getTypeMap()) + .filter(type => isOutputType(type) && !type.name.startsWith('__')) + .map(type => ({ + label: type.name, + kind: CompletionItemKind.Function, + })), + ); + } + if (unwrappedState.kind === RuleKinds.INPUT_VALUE_DEF) { + return hintList( + token, + Object.values(schema.getTypeMap()) + .filter(type => isInputType(type) && !type.name.startsWith('__')) + .map(type => ({ + label: type.name, + kind: CompletionItemKind.Function, + })), + ); } // Variable definition types diff --git a/packages/graphql-language-service/src/interface/getDefinition.ts b/packages/graphql-language-service/src/interface/getDefinition.ts index 952ca33db47..dea00033a19 100644 --- a/packages/graphql-language-service/src/interface/getDefinition.ts +++ b/packages/graphql-language-service/src/interface/getDefinition.ts @@ -124,7 +124,6 @@ export async function getDefinitionQueryResultForFragmentSpread( ({ filePath, content, definition }) => getDefinitionForFragmentDefinition(filePath || '', content, definition), ); - return { definitions, queryRange: definitions.map(_ => getRange(text, fragment)), diff --git a/packages/graphql-language-service/src/types.ts b/packages/graphql-language-service/src/types.ts index 6e4d8c47626..3f0817983d0 100644 --- a/packages/graphql-language-service/src/types.ts +++ b/packages/graphql-language-service/src/types.ts @@ -50,11 +50,6 @@ export interface GraphQLCache { getProjectForFile: (uri: string) => GraphQLProjectConfig | void; - getObjectTypeDependencies: ( - query: string, - fragmentDefinitions: Map, - ) => Promise; - getObjectTypeDependenciesForAST: ( parsedQuery: ASTNode, fragmentDefinitions: Map, @@ -70,12 +65,6 @@ export interface GraphQLCache { contents: CachedContent[], ) => Promise; - updateObjectTypeDefinitionCache: ( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ) => Promise; - getFragmentDependencies: ( query: string, fragmentDefinitions: Maybe>, @@ -95,15 +84,8 @@ export interface GraphQLCache { filePath: Uri, contents: CachedContent[], ) => Promise; - - updateFragmentDefinitionCache: ( - rootDir: Uri, - filePath: Uri, - exists: boolean, - ) => Promise; - getSchema: ( - appName?: string, + appName: string, queryHasExtensions?: boolean, ) => Promise; } @@ -124,9 +106,11 @@ export interface IRange { setStart(line: number, character: number): void; containsPosition(position: IPosition): boolean; } + export type CachedContent = { - query: string; - range: IRange | null; + documentString: string; + range?: IRange; + ast?: DocumentNode; }; // GraphQL Language Service related types @@ -139,12 +123,16 @@ export type GraphQLFileMetadata = { }; export type GraphQLFileInfo = { - filePath: Uri; - content: string; - asts: DocumentNode[]; - queries: CachedContent[]; + // file:// uri string + filePath?: Uri; + // file system path + fsPath?: string; + source: string; + // asts: DocumentNode[]; + contents: CachedContent[]; size: number; mtime: number; + version: number; }; export type AllTypeInfo = { @@ -162,7 +150,10 @@ export type AllTypeInfo = { }; export type FragmentInfo = { + // file:// uri string filePath?: Uri; + // file system path + fsPath?: string; content: string; definition: FragmentDefinitionNode; }; @@ -174,7 +165,10 @@ export type NamedTypeInfo = { }; export type ObjectTypeInfo = { + // file:// uri string filePath?: Uri; + // file system path + fsPath?: string; content: string; definition: TypeDefinitionNode; }; diff --git a/packages/vscode-graphql-syntax/tests/__fixtures__/test.astro b/packages/vscode-graphql-syntax/tests/__fixtures__/test.astro new file mode 100644 index 00000000000..8698868eec3 --- /dev/null +++ b/packages/vscode-graphql-syntax/tests/__fixtures__/test.astro @@ -0,0 +1,30 @@ +--- +const gql = String.raw; +const response = await fetch( + 'https://swapi-graphql.netlify.app/.netlify/functions/index', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: gql` + query getFilm($id: ID!) { + film(id: $id) { + title + releaseDate + } + } + `, + variables: { + id: 'ZmlsbXM6MQ==', + }, + }), + }, +); + +const json = await response.json(); +const { film } = json.data; +--- + +

Fetching information about Star Wars: A New Hope

+

Title: {film.title}

+

Year: {film.releaseDate}

diff --git a/packages/vscode-graphql-syntax/tests/__snapshots__/js-grammar.spec.ts.snap b/packages/vscode-graphql-syntax/tests/__snapshots__/js-grammar.spec.ts.snap index 1be3ebd280f..d9f8179c302 100644 --- a/packages/vscode-graphql-syntax/tests/__snapshots__/js-grammar.spec.ts.snap +++ b/packages/vscode-graphql-syntax/tests/__snapshots__/js-grammar.spec.ts.snap @@ -1,5 +1,69 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`inline.graphql grammar > should tokenize a simple astro file 1`] = ` +--- | +const gql = String.raw; | +const response = await fetch( | + 'https://swapi-graphql.netlify.app/.netlify/functions/index', | + { | + method: 'POST', | + headers: { 'Content-Type': 'application/json' }, | + body: JSON.stringify({ | + query: | + | +gql | entity.name.function.tagged-template.js +\` | punctuation.definition.string.template.begin.js + | meta.embedded.block.graphql +query | meta.embedded.block.graphql keyword.operation.graphql + | meta.embedded.block.graphql +getFilm | meta.embedded.block.graphql entity.name.function.graphql +( | meta.embedded.block.graphql meta.brace.round.graphql +$id | meta.embedded.block.graphql meta.variables.graphql variable.parameter.graphql +: | meta.embedded.block.graphql meta.variables.graphql punctuation.colon.graphql + | meta.embedded.block.graphql meta.variables.graphql +ID | meta.embedded.block.graphql meta.variables.graphql support.type.builtin.graphql +! | meta.embedded.block.graphql meta.variables.graphql keyword.operator.nulltype.graphql +) | meta.embedded.block.graphql meta.brace.round.graphql + | meta.embedded.block.graphql meta.selectionset.graphql +{ | meta.embedded.block.graphql meta.selectionset.graphql punctuation.operation.graphql + | meta.embedded.block.graphql meta.selectionset.graphql +film | meta.embedded.block.graphql meta.selectionset.graphql variable.graphql +( | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql meta.brace.round.directive.graphql +id | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql variable.parameter.graphql +: | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql punctuation.colon.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql +$id | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql variable.graphql +) | meta.embedded.block.graphql meta.selectionset.graphql meta.arguments.graphql meta.brace.round.directive.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql +{ | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql punctuation.operation.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql +title | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql variable.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql +releaseDate | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql variable.graphql + | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql +} | meta.embedded.block.graphql meta.selectionset.graphql meta.selectionset.graphql punctuation.operation.graphql + | meta.embedded.block.graphql meta.selectionset.graphql +} | meta.embedded.block.graphql meta.selectionset.graphql punctuation.operation.graphql + | meta.embedded.block.graphql +\` | punctuation.definition.string.template.end.js +, | + variables: { | + id: 'ZmlsbXM6MQ==', | + }, | + }), | + }, | +); | + | +const json = await response.json(); | +const { film } = json.data; | +--- | + | +

Fetching information about Star Wars: A New Hope

| +

Title: {film.title}

| +

Year: {film.releaseDate}

| + | +`; + exports[`inline.graphql grammar > should tokenize a simple ecmascript file 1`] = ` /* eslint-disable */ | /* prettier-ignore */ | diff --git a/packages/vscode-graphql-syntax/tests/js-grammar.spec.ts b/packages/vscode-graphql-syntax/tests/js-grammar.spec.ts index 91332e32551..786018e4562 100644 --- a/packages/vscode-graphql-syntax/tests/js-grammar.spec.ts +++ b/packages/vscode-graphql-syntax/tests/js-grammar.spec.ts @@ -23,4 +23,8 @@ describe('inline.graphql grammar', () => { const result = await tokenizeFile('__fixtures__/test.svelte', scope); expect(result).toMatchSnapshot(); }); + it('should tokenize a simple astro file', async () => { + const result = await tokenizeFile('__fixtures__/test.astro', scope); + expect(result).toMatchSnapshot(); + }); }); diff --git a/packages/vscode-graphql/README.md b/packages/vscode-graphql/README.md index 6cad22385da..9a828269d22 100644 --- a/packages/vscode-graphql/README.md +++ b/packages/vscode-graphql/README.md @@ -7,28 +7,9 @@ Ecosystem with VSCode for an awesome developer experience. ![](https://camo.githubusercontent.com/97dc1080d5e6883c4eec3eaa6b7d0f29802e6b4b/687474703a2f2f672e7265636f726469742e636f2f497379504655484e5a342e676966) -### General features - -> _Operation Execution will be re-introduced in a new extension_ - -- Load the extension on detecting `graphql-config file` at root level or in a - parent level directory -- Load the extension in `.graphql`, `.gql files` -- Load the extension detecting `gql` tag in js, ts, jsx, tsx, vue files -- Load the extension inside `gql`/`graphql` fenced code blocks in markdown files -- NO LONGER SUPPORTED - execute query/mutation/subscription operations, embedded - or in graphql files - we will be recommending other extensions for this. -- pre-load schema and document definitions -- Support [`graphql-config`](https://graphql-config.com/) files with one project - and multiple projects (multi-workspace roots with multiple graphql config - files not yet supported) -- the language service re-starts on saved changes to vscode settings and/or - graphql config! - -### `.graphql`, `.gql` file extension support +### `.graphql`, `.gql` file extension support and `gql`/`graphql` tagged template literal support for tsx, jsx, ts, js -- syntax highlighting (type, query, mutation, interface, union, enum, scalar, - fragments, directives) +- syntax highlighting (provided by `vscode-graphql-syntax`) - autocomplete suggestions - validation against schema - snippets (interface, type, input, enum, union) @@ -36,80 +17,67 @@ Ecosystem with VSCode for an awesome developer experience. - go to definition support (input, enum, type) - outline support -### `gql`/`graphql` tagged template literal support for tsx, jsx, ts, js +## Getting Started -- syntax highlighting (type, query, mutation, interface, union, enum, scalar, - fragments, directives) -- autocomplete suggestions -- validation against schema -- snippets -- hover support -- go to definition for fragments and input types -- outline support +> **This extension requires a graphql-config file**. -## Usage +To support language features like completion and "go-to definition" across multiple files, +please include `documents` in the `graphql-config` file default or per-project -**This extension requires a graphql-config file**. +### Simplest Config Example -Install the -[VSCode GraphQL Extension](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql). - -(Watchman is no longer required, you can uninstall it now) +```yaml +# .graphqlrc.yml or graphql.config.yml +schema: 'schema.graphql' +documents: 'src/**/*.{graphql,js,ts,jsx,tsx}' +``` -As of `vscode-graphql@0.3.0` we support `graphql-config@3`. You can read more -about that [here](https://www.graphql-config.com/docs/user/user-usage). Because -it now uses `cosmiconfig` there are plenty of new options for loading config -files: +`package.json`: +```json +"graphql": { + "schema": "https://localhost:3001", + "documents": "**/*.{graphql,js,ts,jsx,tsx}" +}, ``` -graphql.config.json -graphql.config.js -graphql.config.yaml -graphql.config.yml -.graphqlrc (YAML or JSON) -.graphqlrc.json -.graphqlrc.yaml -.graphqlrc.yml -.graphqlrc.js -graphql property in package.json -``` - -the file needs to be placed at the project root by default, but you can -configure paths per project. see the FAQ below for details. -Previous versions of this extension support `graphql-config@2` format, which -follows -[legacy configuration patterns](https://github.com/kamilkisiela/graphql-config/tree/legacy#usage) +```ts +// .graphqlrc.ts or graphql.config.ts +export default { + schema: 'schema.graphql', + documents: '**/*.{graphql,js,ts,jsx,tsx}', +}; +``` -If you need legacy support for `.graphqlconfig` files or older graphql-config -formats, see [this FAQ answer](#legacy). If you are missing legacy -`graphql-config` features, please consult -[the `graphql-config` repository](https://github.com/kamilkisiela/graphql-config). +## Additional Features -To support language features like "go-to definition" across multiple files, -please include `documents` key in the `graphql-config` file default or -per-project (this was `include` in 2.0). +- Loads the LSP server upon detecting a `graphql-config` file at root level or in a + parent level directory, or a `package.json` file with `graphql` config +- Loads `.graphql`, `.gql` files, and detects `gql`, `graphql` tags and `/** GraphQL */` and `#graphql` comments in js, ts, jsx, tsx, vue files +- pre-load schema and fragment definitions +- Support [`graphql-config`](https://graphql-config.com/) files with one project + and multiple projects (multi-workspace roots with multiple graphql config + files not yet supported) +- the language service re-starts on saved changes to vscode settings and/or + graphql config! ## Configuration Examples For more help with configuring the language server, [the language server readme](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service-server/README.md) is the source of truth for all settings used by all editors which use the -language server. +language server. The [`graphql-config`](https://graphql-config.com/) docs are also very helpful. -### Simple Example +### Advanced Example -```yaml -# .graphqlrc.yml -schema: 'schema.graphql' -documents: 'src/**/*.{graphql,js,ts,jsx,tsx}' -``` +Multi-project can be used for both local files, URL defined schema, or both -### Advanced Example +```ts +import dotenv from 'dotenv'; +dotenv.config(); -```js -// graphql.config.js -module.exports = { +// .graphqlrc.ts or graphql.config.ts +export default { projects: { app: { schema: ['src/schema.graphql', 'directives.graphql'], @@ -119,15 +87,15 @@ module.exports = { schema: 'src/generated/db.graphql', documents: ['src/db/**/*.graphql', 'my/fragments.graphql'], extensions: { - codegen: [ - { - generator: 'graphql-binding', - language: 'typescript', - output: { - binding: 'src/generated/db.ts', + // for use with `vscode-graphql-execution`, for example: + endpoints: { + default: { + url: 'https://localhost:3001/graphql/', + headers: { + Authorization: `Bearer ${process.env.API_TOKEN}`, }, }, - ], + }, }, }, }, @@ -139,66 +107,9 @@ is also valid. ## Frequently Asked Questions - - -### I can't load `.graphqlconfig` files anymore - -> Note: this option has been set to be enabled by default, however -> `graphql-config` maintainers do not want to continue to support the legacy -> format (mostly kept for companies where intellij users are stuck on the old -> config format), so please migrate to the new `graphql-config` format as soon -> as possible! - -If you need to use a legacy config file, then you just need to enable legacy -mode for `graphql-config`: - -```json -"graphql-config.load.legacy": true -``` - -### Go to definition is not working for my URL - -You can try the new experimental `cacheSchemaFileForLookup` option. NOTE: this -will disable all definition lookup for local SDL graphql schema files, and -_only_ perform lookup of the result an SDL result of `graphql-config` -`getSchema()` - -To enable, add this to your settings: - -```json -"vscode-graphql.cacheSchemaFileForLookup": true, -``` - -you can also use graphql config if you need to mix and match these settings: - -```yml -schema: 'http://myschema.com/graphql' -extensions: - languageService: - cacheSchemaFileForLookup: true -projects: - project1: - schema: 'project1/schema/schema.graphql' - documents: 'project1/queries/**/*.{graphql,tsx,jsx,ts,js}' - extensions: - languageService: - cacheSchemaFileForLookup: false - - project2: - schema: 'https://api.spacex.land/graphql/' - documents: 'project2/queries.graphql' - extensions: - endpoints: - default: - url: 'https://api.spacex.land/graphql/' - languageService: - # Do project configs inherit parent config? - cacheSchemaFileForLookup: true -``` - ### The extension fails with errors about duplicate types -Make sure that you aren't including schema files in the `documents` blob +Your object types must be unique per project (as they must be unique per schema), and your fragment names must also be unique per project. ### The extension fails with errors about missing scalars, directives, etc @@ -232,6 +143,7 @@ You can search a folder for any of the matching config file names listed above: ```json "graphql-config.load.rootDir": "./config" +"graphql-config.envFilePath": "./config/.dev.env" ``` Or a specific filepath: @@ -253,39 +165,15 @@ which would search for `./config/.acmerc`, `.config/.acmerc.js`, If you have multiple projects, you need to define one top-level config that defines all project configs using `projects` -### How do I highlight an embedded graphql string? - -If you aren't using a template tag function such as `gql` or `graphql`, and just -want to use a plain string, you can use an inline `#graphql` comment: +### How do I enable language features for an embedded graphql string? -```ts -const myQuery = `#graphql - query { - something - } -`; -``` - -or - -```ts -const myQuery = - /* GraphQL */ - - ` - query { - something - } - `; -``` +Please refer to the `vscode-graphql-syntax` reference files ([js](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.js),[ts](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.ts),[svelte](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.svelte),[vue](https://github.com/graphql/graphiql/blob/main/packages/vscode-graphql-syntax/tests/__fixtures__/test.vue)) to learn our template tag, comment and other graphql delimiter patterns for the file types that the language server supports. The syntax highlighter currently supports more languages than the language server. If you notice any places where one or the other doesn't work, please report it! ## Known Issues -- the output channel occasionally shows "definition not found" when you first - start the language service, but once the definition cache is built for each - project, definition lookup will work. so if a "peek definition" fails when you - first start the editor or when you first install the extension, just try the - definition lookup again. +- the locally generated schema file for definition lookup currently does not re-generate on schema changes. this will be fixed soon. +- multi-root workspaces support will be added soon as well. +- some graphql-config options aren't always honored, this will also be fixed soon ## Attribution @@ -312,7 +200,7 @@ This plugin uses the ### Contributing back to this project This repository is managed by EasyCLA. Project participants must sign the free -([GraphQL Specification Membership agreement](https://preview-spec-membership.graphql.org) +([GraphQL Specification Membership agreement](https://preview-spec-membership.graphql.org)) before making a contribution. You only need to do this one time, and it can be signed by [individual contributors](http://individual-spec-membership.graphql.org/) or diff --git a/packages/vscode-graphql/package.json b/packages/vscode-graphql/package.json index 767b58a127f..4df696837cd 100644 --- a/packages/vscode-graphql/package.json +++ b/packages/vscode-graphql/package.json @@ -1,6 +1,6 @@ { "name": "vscode-graphql", - "version": "0.9.3", + "version": "0.10.1", "private": true, "license": "MIT", "displayName": "GraphQL: Language Feature Support", @@ -85,13 +85,13 @@ "null" ], "default": false, - "description": "Enable debug logs" + "description": "Enable debug logs and node debugger for client" }, "vscode-graphql.cacheSchemaFileForLookup": { "type": [ "boolean" ], - "description": "Use a cached file output of your graphql-config schema result for definition lookups, symbols, outline, etc. Disabled by default." + "description": "Use a cached file output of your graphql-config schema result for definition lookups, symbols, outline, etc. Enabled by default when one or more schema entry is not a local file with SDL in it. Disable if you want to use SDL with a generated schema." }, "vscode-graphql.largeFileThreshold": { "type": [ @@ -111,36 +111,36 @@ "type": [ "string" ], - "description": "Base dir for graphql config loadConfig()" + "description": "Base dir for graphql config loadConfig(), to look for config files or package.json" }, "graphql-config.load.filePath": { "type": [ "string" ], - "description": "filePath for graphql config loadConfig()", - "default": null - }, - "graphql-config.load.legacy": { - "type": [ - "boolean" - ], - "description": "legacy mode for graphql config v2 config", + "description": "exact filePath for a `graphql-config` file `loadConfig()`", "default": null }, "graphql-config.load.configName": { "type": [ "string" ], - "description": "optional .config.js instead of default `graphql`", + "description": "optional .config.{js,ts,toml,yaml,json} & rc* instead of default `graphql`", "default": null }, - "graphql-config.dotEnvPath": { + "graphql-config.load.legacy": { "type": [ - "string" + "boolean" ], - "description": "optional .env load path, if not the default", + "description": "legacy mode for graphql config v2 config", "default": null } + }, + "graphql-config.dotEnvPath": { + "type": [ + "string" + ], + "description": "optional .env load file path, if not the default. specify a relative path to the .env file to be loaded by dotenv module. you can also import dotenv in the config file.", + "default": null } }, "commands": [ @@ -161,7 +161,7 @@ "vsce:package": "vsce package --yarn", "env:source": "export $(cat .envrc | xargs)", "vsce:publish": "vsce publish --yarn", - "open-vsx:publish": "ovsx publish --extensionFile $(ls -1 *.vsix | tail -n 1) --pat $OVSX_PAT", + "open-vsx:publish": "ovsx publish $(ls -1 *.vsix | tail -n 1) --pat $OVSX_PAT", "release": "npm run vsce:publish && npm run open-vsx:publish" }, "devDependencies": { diff --git a/packages/vscode-graphql/src/extension.ts b/packages/vscode-graphql/src/extension.ts index 56a244b0eb2..70683342c13 100644 --- a/packages/vscode-graphql/src/extension.ts +++ b/packages/vscode-graphql/src/extension.ts @@ -77,6 +77,7 @@ export async function activate(context: ExtensionContext) { ), // TODO: load ignore // These ignore node_modules and .git by default + // json is included so we can detect potential `graphql` config changes to package.json workspace.createFileSystemWatcher( '**/{*.graphql,*.graphqls,*.gql,*.js,*.mjs,*.cjs,*.esm,*.es,*.es6,*.jsx,*.ts,*.tsx,*.vue,*.svelte,*.cts,*.mts,*.json,*.astro}', ), diff --git a/yarn.lock b/yarn.lock index 96831d6a942..ade5e4da403 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1036,6 +1036,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.7.tgz#df8cf085ce92ddbdbf668a7f186ce848c9036cae" integrity sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q== +"@babel/parser@^7.24.0": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.0.tgz#26a3d1ff49031c53a97d03b604375f028746a9ac" + integrity sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2" @@ -2388,9 +2393,9 @@ "@babel/types" "^7.12.13" "@babel/traverse@^7.16.8", "@babel/traverse@^7.17.3", "@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.22.5", "@babel/traverse@^7.22.6", "@babel/traverse@^7.22.8", "@babel/traverse@^7.23.2", "@babel/traverse@^7.23.7", "@babel/traverse@^7.7.2": - version "7.23.7" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305" - integrity sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg== + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.0.tgz#4a408fbf364ff73135c714a2ab46a5eab2831b1e" + integrity sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw== dependencies: "@babel/code-frame" "^7.23.5" "@babel/generator" "^7.23.6" @@ -2398,8 +2403,8 @@ "@babel/helper-function-name" "^7.23.0" "@babel/helper-hoist-variables" "^7.22.5" "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.23.6" - "@babel/types" "^7.23.6" + "@babel/parser" "^7.24.0" + "@babel/types" "^7.24.0" debug "^4.3.1" globals "^11.1.0" @@ -2455,6 +2460,15 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" +"@babel/types@^7.24.0": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.0.tgz#3b951f435a92e7333eba05b7566fd297960ea1bf" + integrity sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w== + dependencies: + "@babel/helper-string-parser" "^7.23.4" + "@babel/helper-validator-identifier" "^7.22.20" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -3519,6 +3533,16 @@ tslib "^2.4.0" value-or-promise "^1.0.12" +"@graphql-tools/batch-execute@^9.0.4": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/batch-execute/-/batch-execute-9.0.4.tgz#11601409c0c33491971fc82592de12390ec58be2" + integrity sha512-kkebDLXgDrep5Y0gK1RN3DMUlLqNhg60OAz0lTCqrYeja6DshxLtLkj+zV4mVbBA4mQOEoBmw6g1LZs3dA84/w== + dependencies: + "@graphql-tools/utils" "^10.0.13" + dataloader "^2.2.2" + tslib "^2.4.0" + value-or-promise "^1.0.12" + "@graphql-tools/code-file-loader@8.0.3": version "8.0.3" resolved "https://registry.yarnpkg.com/@graphql-tools/code-file-loader/-/code-file-loader-8.0.3.tgz#8e1e8c2fc05c94614ce25c3cee36b3b4ec08bb64" @@ -3543,6 +3567,18 @@ tslib "^2.5.0" value-or-promise "^1.0.12" +"@graphql-tools/delegate@^10.0.4": + version "10.0.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/delegate/-/delegate-10.0.4.tgz#7c38240f11e42ec2dd45d0a569ca6433ce4cb8dc" + integrity sha512-WswZRbQZMh/ebhc8zSomK9DIh6Pd5KbuiMsyiKkKz37TWTrlCOe+4C/fyrBFez30ksq6oFyCeSKMwfrCbeGo0Q== + dependencies: + "@graphql-tools/batch-execute" "^9.0.4" + "@graphql-tools/executor" "^1.2.1" + "@graphql-tools/schema" "^10.0.3" + "@graphql-tools/utils" "^10.0.13" + dataloader "^2.2.2" + tslib "^2.5.0" + "@graphql-tools/executor-graphql-ws@^1.0.0": version "1.0.1" resolved "https://registry.yarnpkg.com/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-1.0.1.tgz#d0ff56518ae2ff2087f35f91b07a03f137905bc9" @@ -3556,6 +3592,18 @@ tslib "^2.4.0" ws "8.13.0" +"@graphql-tools/executor-graphql-ws@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-1.1.2.tgz#2bf959d2319692460b39400c0fe1515dfbb9f034" + integrity sha512-+9ZK0rychTH1LUv4iZqJ4ESbmULJMTsv3XlFooPUngpxZkk00q6LqHKJRrsLErmQrVaC7cwQCaRBJa0teK17Lg== + dependencies: + "@graphql-tools/utils" "^10.0.13" + "@types/ws" "^8.0.0" + graphql-ws "^5.14.0" + isomorphic-ws "^5.0.0" + tslib "^2.4.0" + ws "^8.13.0" + "@graphql-tools/executor-http@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@graphql-tools/executor-http/-/executor-http-1.0.0.tgz#3d7f1ce70dcc40432fb92b970bd1ab4dd1c37b12" @@ -3570,6 +3618,19 @@ tslib "^2.4.0" value-or-promise "^1.0.12" +"@graphql-tools/executor-http@^1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@graphql-tools/executor-http/-/executor-http-1.0.9.tgz#87ca8b99a32241eb0cc30a9c500d2672e92d58b7" + integrity sha512-+NXaZd2MWbbrWHqU4EhXcrDbogeiCDmEbrAN+rMn4Nu2okDjn2MTFDbTIab87oEubQCH4Te1wDkWPKrzXup7+Q== + dependencies: + "@graphql-tools/utils" "^10.0.13" + "@repeaterjs/repeater" "^3.0.4" + "@whatwg-node/fetch" "^0.9.0" + extract-files "^11.0.0" + meros "^1.2.1" + tslib "^2.4.0" + value-or-promise "^1.0.12" + "@graphql-tools/executor-legacy-ws@^1.0.0": version "1.0.1" resolved "https://registry.yarnpkg.com/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.0.1.tgz#49764812fc93f401cb3f3ef32b2d6db4a9cd8db5" @@ -3581,6 +3642,17 @@ tslib "^2.4.0" ws "8.13.0" +"@graphql-tools/executor-legacy-ws@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.0.6.tgz#4ed311b731db8fd5c99e66a66361afbf9c2109fc" + integrity sha512-lDSxz9VyyquOrvSuCCnld3256Hmd+QI2lkmkEv7d4mdzkxkK4ddAWW1geQiWrQvWmdsmcnGGlZ7gDGbhEExwqg== + dependencies: + "@graphql-tools/utils" "^10.0.13" + "@types/ws" "^8.0.0" + isomorphic-ws "^5.0.0" + tslib "^2.4.0" + ws "^8.15.0" + "@graphql-tools/executor@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@graphql-tools/executor/-/executor-1.1.0.tgz#bafddb7c56d8250c5eda83437c10664e702109a8" @@ -3592,6 +3664,17 @@ tslib "^2.4.0" value-or-promise "^1.0.12" +"@graphql-tools/executor@^1.2.1": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@graphql-tools/executor/-/executor-1.2.2.tgz#08796bb70f3c0a480d446cc9d01e5694899f7450" + integrity sha512-wZkyjndwlzi01HTU3PDveoucKA8qVO0hdKmJhjIGK/vRN/A4w5rDdeqRGcyXVss0clCAy3R6jpixCVu5pWs2Qg== + dependencies: + "@graphql-tools/utils" "^10.1.1" + "@graphql-typed-document-node/core" "3.2.0" + "@repeaterjs/repeater" "^3.0.4" + tslib "^2.4.0" + value-or-promise "^1.0.12" + "@graphql-tools/graphql-file-loader@^8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.0.0.tgz#a2026405bce86d974000455647511bf65df4f211" @@ -3603,6 +3686,17 @@ tslib "^2.4.0" unixify "^1.0.0" +"@graphql-tools/graphql-file-loader@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.0.1.tgz#03869b14cb91d0ef539df8195101279bb2df9c9e" + integrity sha512-7gswMqWBabTSmqbaNyWSmRRpStWlcCkBc73E6NZNlh4YNuiyKOwbvSkOUYFOqFMfEL+cFsXgAvr87Vz4XrYSbA== + dependencies: + "@graphql-tools/import" "7.0.1" + "@graphql-tools/utils" "^10.0.13" + globby "^11.0.3" + tslib "^2.4.0" + unixify "^1.0.0" + "@graphql-tools/graphql-tag-pluck@8.1.0": version "8.1.0" resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.1.0.tgz#0745b6f0103eb725f10c5d4c1a9438670bb8e05b" @@ -3616,6 +3710,19 @@ "@graphql-tools/utils" "^10.0.0" tslib "^2.4.0" +"@graphql-tools/graphql-tag-pluck@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.0.tgz#11bb8c627253137b39b34fb765cd6ebe506388b9" + integrity sha512-gNqukC+s7iHC7vQZmx1SEJQmLnOguBq+aqE2zV2+o1hxkExvKqyFli1SY/9gmukFIKpKutCIj+8yLOM+jARutw== + dependencies: + "@babel/core" "^7.22.9" + "@babel/parser" "^7.16.8" + "@babel/plugin-syntax-import-assertions" "^7.20.0" + "@babel/traverse" "^7.16.8" + "@babel/types" "^7.16.8" + "@graphql-tools/utils" "^10.0.13" + tslib "^2.4.0" + "@graphql-tools/import@7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@graphql-tools/import/-/import-7.0.0.tgz#a6a91a90a707d5f46bad0fd3fde2f407b548b2be" @@ -3625,6 +3732,15 @@ resolve-from "5.0.0" tslib "^2.4.0" +"@graphql-tools/import@7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@graphql-tools/import/-/import-7.0.1.tgz#4e0d181c63350b1c926ae91b84a4cbaf03713c2c" + integrity sha512-935uAjAS8UAeXThqHfYVr4HEAp6nHJ2sximZKO1RzUTq5WoALMAhhGARl0+ecm6X+cqNUwIChJbjtaa6P/ML0w== + dependencies: + "@graphql-tools/utils" "^10.0.13" + resolve-from "5.0.0" + tslib "^2.4.0" + "@graphql-tools/json-file-loader@^8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@graphql-tools/json-file-loader/-/json-file-loader-8.0.0.tgz#9b1b62902f766ef3f1c9cd1c192813ea4f48109c" @@ -3653,6 +3769,14 @@ "@graphql-tools/utils" "^10.0.0" tslib "^2.4.0" +"@graphql-tools/merge@^9.0.3": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-9.0.3.tgz#4d0b467132e6f788b69fab803d31480b8ce4b61a" + integrity sha512-FeKv9lKLMwqDu0pQjPpF59GY3HReUkWXKsMIuMuJQOKh9BETu7zPEFUELvcw8w+lwZkl4ileJsHXC9+AnsT2Lw== + dependencies: + "@graphql-tools/utils" "^10.0.13" + tslib "^2.4.0" + "@graphql-tools/schema@^10.0.0": version "10.0.0" resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-10.0.0.tgz#7b5f6b6a59f51c927de8c9069bde4ebbfefc64b3" @@ -3663,6 +3787,16 @@ tslib "^2.4.0" value-or-promise "^1.0.12" +"@graphql-tools/schema@^10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-10.0.3.tgz#48c14be84cc617c19c4c929258672b6ab01768de" + integrity sha512-p28Oh9EcOna6i0yLaCFOnkcBDQECVf3SCexT6ktb86QNj9idnkhI+tCxnwZDh58Qvjd2nURdkbevvoZkvxzCog== + dependencies: + "@graphql-tools/merge" "^9.0.3" + "@graphql-tools/utils" "^10.0.13" + tslib "^2.4.0" + value-or-promise "^1.0.12" + "@graphql-tools/url-loader@^8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@graphql-tools/url-loader/-/url-loader-8.0.0.tgz#8d952d5ebb7325e587cb914aaebded3dbd078cf6" @@ -3682,6 +3816,25 @@ value-or-promise "^1.0.11" ws "^8.12.0" +"@graphql-tools/url-loader@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@graphql-tools/url-loader/-/url-loader-8.0.2.tgz#ee8e10a85d82c72662f6bc6bbc7b408510a36ebd" + integrity sha512-1dKp2K8UuFn7DFo1qX5c1cyazQv2h2ICwA9esHblEqCYrgf69Nk8N7SODmsfWg94OEaI74IqMoM12t7eIGwFzQ== + dependencies: + "@ardatan/sync-fetch" "^0.0.1" + "@graphql-tools/delegate" "^10.0.4" + "@graphql-tools/executor-graphql-ws" "^1.1.2" + "@graphql-tools/executor-http" "^1.0.9" + "@graphql-tools/executor-legacy-ws" "^1.0.6" + "@graphql-tools/utils" "^10.0.13" + "@graphql-tools/wrap" "^10.0.2" + "@types/ws" "^8.0.0" + "@whatwg-node/fetch" "^0.9.0" + isomorphic-ws "^5.0.0" + tslib "^2.4.0" + value-or-promise "^1.0.11" + ws "^8.12.0" + "@graphql-tools/utils@^10.0.0": version "10.0.1" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-10.0.1.tgz#52e6c0ce920b57473823e487184f5017974fe4c4" @@ -3690,6 +3843,16 @@ "@graphql-typed-document-node/core" "^3.1.1" tslib "^2.4.0" +"@graphql-tools/utils@^10.0.13", "@graphql-tools/utils@^10.1.1", "@graphql-tools/utils@^10.1.2": + version "10.1.2" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-10.1.2.tgz#192de00e7301c0242e7305ab16bbeef76bbcec74" + integrity sha512-fX13CYsDnX4yifIyNdiN0cVygz/muvkreWWem6BBw130+ODbRRgfiVveL0NizCEnKXkpvdeTy9Bxvo9LIKlhrw== + dependencies: + "@graphql-typed-document-node/core" "^3.1.1" + cross-inspect "1.0.0" + dset "^3.1.2" + tslib "^2.4.0" + "@graphql-tools/wrap@^10.0.0": version "10.0.0" resolved "https://registry.yarnpkg.com/@graphql-tools/wrap/-/wrap-10.0.0.tgz#573ab111482387d4acf4757d5fb7f9553a504bc1" @@ -3701,6 +3864,17 @@ tslib "^2.4.0" value-or-promise "^1.0.12" +"@graphql-tools/wrap@^10.0.2": + version "10.0.5" + resolved "https://registry.yarnpkg.com/@graphql-tools/wrap/-/wrap-10.0.5.tgz#614b964a158887b4a644f5425b2b9a57b5751f72" + integrity sha512-Cbr5aYjr3HkwdPvetZp1cpDWTGdD1Owgsb3z/ClzhmrboiK86EnQDxDvOJiQkDCPWE9lNBwj8Y4HfxroY0D9DQ== + dependencies: + "@graphql-tools/delegate" "^10.0.4" + "@graphql-tools/schema" "^10.0.3" + "@graphql-tools/utils" "^10.1.1" + tslib "^2.4.0" + value-or-promise "^1.0.12" + "@graphql-typed-document-node/core@3.2.0": version "3.2.0" resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" @@ -5453,6 +5627,13 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-9.1.1.tgz#e7c4f1001eefa4b8afbd1eee27a237fee3bf29c4" integrity sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw== +"@types/mock-fs@^4.13.4": + version "4.13.4" + resolved "https://registry.yarnpkg.com/@types/mock-fs/-/mock-fs-4.13.4.tgz#e73edb4b4889d44d23f1ea02d6eebe50aa30b09a" + integrity sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg== + dependencies: + "@types/node" "*" + "@types/ms@*": version "0.7.31" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" @@ -6050,6 +6231,35 @@ loupe "^2.3.6" pretty-format "^27.5.1" +"@vscode/vsce@^2.19.0", "@vscode/vsce@^2.23.0": + version "2.23.0" + resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-2.23.0.tgz#280ce82356c59efda97d3ba14bcdd9e3e22ddb7f" + integrity sha512-Wf9yN8feZf4XmUW/erXyKQvCL577u72AQv4AI4Cwt5o5NyE49C5mpfw3pN78BJYYG3qnSIxwRo7JPvEurkQuNA== + dependencies: + azure-devops-node-api "^11.0.1" + chalk "^2.4.2" + cheerio "^1.0.0-rc.9" + commander "^6.2.1" + find-yarn-workspace-root "^2.0.0" + glob "^7.0.6" + hosted-git-info "^4.0.2" + jsonc-parser "^3.2.0" + leven "^3.1.0" + markdown-it "^12.3.2" + mime "^1.3.4" + minimatch "^3.0.3" + parse-semver "^1.1.1" + read "^1.0.7" + semver "^7.5.2" + tmp "^0.2.1" + typed-rest-client "^1.8.4" + url-join "^4.0.1" + xml2js "^0.5.0" + yauzl "^2.3.1" + yazl "^2.2.2" + optionalDependencies: + keytar "^7.7.0" + "@vscode/vsce@^2.22.1-2": version "2.22.1-2" resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-2.22.1-2.tgz#0f272f97b23986366ea97e29721cb28410b9af68" @@ -7503,30 +7713,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001328: - version "1.0.30001450" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001450.tgz" - integrity sha512-qMBmvmQmFXaSxexkjjfMvD5rnDL0+m+dUMZKoDYsGG8iZN29RuYh9eRoMvKsT6uMAWlyUUGDEQGJJYjzCIO9ew== - -caniuse-lite@^1.0.30001406: - version "1.0.30001507" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001507.tgz#fae53f6286e7564783eadea9b447819410a59534" - integrity sha512-SFpUDoSLCaE5XYL2jfqe9ova/pbQHEmbheDf5r4diNwbAgR3qxM9NQtfsiSscjqoya5K7kFcHPUQ+VsUkIJR4A== - -caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449: - version "1.0.30001457" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001457.tgz#6af34bb5d720074e2099432aa522c21555a18301" - integrity sha512-SDIV6bgE1aVbK6XyxdURbUE89zY7+k1BBBaOwYwkNCglXlel/E7mELiHC64HQ+W0xSKlqWhV9Wh7iHxUjMs4fA== - -caniuse-lite@^1.0.30001517: - version "1.0.30001518" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001518.tgz#b3ca93904cb4699c01218246c4d77a71dbe97150" - integrity sha512-rup09/e3I0BKjncL+FesTayKtPrdwKhUufQFd3riFw1hHg8JmIFoInYfB102cFcY/pPgGmdyl/iy+jgiDi2vdA== - -caniuse-lite@^1.0.30001565: - version "1.0.30001574" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz#fb4f1359c77f6af942510493672e1ec7ec80230c" - integrity sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001328, caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001565: + version "1.0.30001588" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001588.tgz" + integrity sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ== capital-case@^1.0.4: version "1.0.4" @@ -8372,6 +8562,13 @@ cross-env@^7.0.2: dependencies: cross-spawn "^7.0.1" +cross-inspect@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cross-inspect/-/cross-inspect-1.0.0.tgz#5fda1af759a148594d2d58394a9e21364f6849af" + integrity sha512-4PFfn4b5ZN6FMNGSZlyb7wUhuN8wvj8t/VQHZdM4JsDcruGJ8L2kf9zao98QIrBPFCpdk27qst/AGTl7pL3ypQ== + dependencies: + tslib "^2.4.0" + cross-spawn@^5.0.1, cross-spawn@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -10698,6 +10895,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.0.tgz#06441868281c86d0dda4ad8bdaead2d02dca89d4" integrity sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ== +follow-redirects@^1.13.2: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== + follow-redirects@^1.14.6: version "1.15.2" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" @@ -11401,6 +11603,11 @@ graphql-ws@5.14.0: resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-5.14.0.tgz#766f249f3974fc2c48fae0d1fb20c2c4c79cd591" integrity sha512-itrUTQZP/TgswR4GSSYuwWUzrE/w5GhbwM2GX3ic2U7aw33jgEsayfIlvaj7/GcIvZgNMzsPTrE5hqPuFUiE5g== +graphql-ws@^5.14.0: + version "5.15.0" + resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-5.15.0.tgz#2db79e1b42468a8363bf5ca6168d076e2f8fdebc" + integrity sha512-xWGAtm3fig9TIhSaNsg0FaDZ8Pyn/3re3RFlP4rhQcmjRDIPpk1EhRuNB+YSJtLzttyuToaDiNhwT1OMoGnJnw== + graphql-ws@^5.5.5: version "5.5.5" resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-5.5.5.tgz#f375486d3f196e2a2527b503644693ae3a8670a9" @@ -15287,15 +15494,28 @@ outdent@^0.5.0: resolved "https://registry.yarnpkg.com/outdent/-/outdent-0.5.0.tgz#9e10982fdc41492bb473ad13840d22f9655be2ff" integrity sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q== -ovsx@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/ovsx/-/ovsx-0.5.1.tgz#3a8c2707ea120d542d1d226a47f24ac47b078710" - integrity sha512-3OWq0l7DuVHi2bd2aQe5+QVQlFIqvrcw3/2vGXL404L6Tr+R4QHtzfnYYghv8CCa85xJHjU0RhcaC7pyXkAUbg== +ovsx@0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/ovsx/-/ovsx-0.8.3.tgz#3c67a595e423f3f70a3d62da3735dd07dfbca69f" + integrity sha512-LG7wTzy4eYV/KolFeO4AwWPzQSARvCONzd5oHQlNvYOlji2r/zjbdK8pyObZN84uZlk6rQBWrJrAdJfh/SX0Hg== dependencies: + "@vscode/vsce" "^2.19.0" commander "^6.1.0" follow-redirects "^1.14.6" is-ci "^2.0.0" leven "^3.1.0" + semver "^7.5.2" + tmp "^0.2.1" + +ovsx@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/ovsx/-/ovsx-0.3.0.tgz#2f30c80c90fbe3c8fc406730c35371219187ca0a" + integrity sha512-UjZjzLt6Iq7LS/XFvEuBAWyn0zmsZEe8fuy5DsbcsIb0mW7PbVtB5Dhe4HeK7NJM228nyhYXC9WCeyBoMi4M3A== + dependencies: + commander "^6.1.0" + follow-redirects "^1.13.2" + is-ci "^2.0.0" + leven "^3.1.0" tmp "^0.2.1" vsce "^2.6.3" @@ -20435,6 +20655,11 @@ ws@^7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== +ws@^8.13.0, ws@^8.15.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" + integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + ws@^8.4.2: version "8.12.1" resolved "https://registry.yarnpkg.com/ws/-/ws-8.12.1.tgz#c51e583d79140b5e42e39be48c934131942d4a8f"