diff --git a/packages/graphql-language-service-server/src/MessageProcessor.ts b/packages/graphql-language-service-server/src/MessageProcessor.ts index 25a5bba8b68..af8676e31f5 100644 --- a/packages/graphql-language-service-server/src/MessageProcessor.ts +++ b/packages/graphql-language-service-server/src/MessageProcessor.ts @@ -7,58 +7,107 @@ * */ -import { existsSync } from 'fs'; -import { CachedContent, GraphQLConfig } from 'graphql-language-service'; import mkdirp from 'mkdirp'; +import { readFileSync, existsSync, writeFileSync, writeFile } from 'fs'; import * as path from 'path'; +import glob from 'fast-glob'; import { URI } from 'vscode-uri'; +import { + CachedContent, + Uri, + GraphQLConfig, + GraphQLProjectConfig, + FileChangeTypeKind, + Range, + Position, + IPosition, +} from 'graphql-language-service'; + +import { GraphQLLanguageService } from './GraphQLLanguageService'; import type { CompletionParams, - DidChangeConfigurationParams, - DidOpenTextDocumentParams, - DidSaveTextDocumentParams, FileEvent, + VersionedTextDocumentIdentifier, + DidSaveTextDocumentParams, + DidOpenTextDocumentParams, + DidChangeConfigurationParams, } from 'vscode-languageserver/node'; import type { - CancellationToken, + Diagnostic, CompletionItem, CompletionList, - Connection, - DidChangeConfigurationRegistrationOptions, - DidChangeTextDocumentParams, - DidChangeWatchedFilesParams, - DidCloseTextDocumentParams, - DocumentSymbolParams, + CancellationToken, Hover, - InitializeParams, InitializeResult, Location, PublishDiagnosticsParams, - SymbolInformation, + DidChangeTextDocumentParams, + DidCloseTextDocumentParams, + DidChangeWatchedFilesParams, + InitializeParams, + Range as RangeType, + Position as VscodePosition, TextDocumentPositionParams, + DocumentSymbolParams, + SymbolInformation, WorkspaceSymbolParams, + Connection, + DidChangeConfigurationRegistrationOptions, } from 'vscode-languageserver/node'; -import { parseDocument } from './parseDocument'; +import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load'; + +import { getGraphQLCache, GraphQLCache } from './GraphQLCache'; +import { parseDocument, DEFAULT_SUPPORTED_EXTENSIONS } from './parseDocument'; -import { tmpdir } from 'os'; import { Logger } from './Logger'; +import { printSchema, visit, parse, FragmentDefinitionNode } from 'graphql'; +import { tmpdir } from 'os'; +import { + ConfigEmptyError, + ConfigInvalidError, + ConfigNotFoundError, + GraphQLExtensionDeclaration, + LoaderNoResultError, + ProjectNotFoundError, +} from 'graphql-config'; import type { LoadConfigOptions } from './types'; -import { WorkspaceMessageProcessor } from './WorkspaceMessageProcessor'; +import { promisify } from 'util'; + +const writeFileAsync = promisify(writeFile); + +const configDocLink = + 'https://www.npmjs.com/package/graphql-language-service-server#user-content-graphql-configuration-file'; + +type CachedDocumentType = { + version: number; + contents: CachedContent[]; +}; +function toPosition(position: VscodePosition): IPosition { + return new Position(position.line, position.character); +} export class MessageProcessor { _connection: Connection; + _graphQLCache!: GraphQLCache; _graphQLConfig: GraphQLConfig | undefined; + _languageService!: GraphQLLanguageService; + _textDocumentCache: Map; + _isInitialized: boolean; + _isGraphQLConfigMissing: boolean | null = null; _willShutdown: boolean; _logger: Logger; + _extensions?: GraphQLExtensionDeclaration[]; _parser: (text: string, uri: string) => CachedContent[]; _tmpDir: string; + _tmpUriBase: string; + _tmpDirBase: string; _loadConfigOptions: LoadConfigOptions; + _schemaCacheInit = false; _rootPath: string = process.cwd(); - _sortedWorkspaceUris: string[] = []; - _processors: Map = new Map(); + _settings: any; constructor({ logger, @@ -80,6 +129,8 @@ export class MessageProcessor { connection: Connection; }) { this._connection = connection; + this._textDocumentCache = new Map(); + this._isInitialized = false; this._willShutdown = false; this._logger = logger; this._graphQLConfig = config; @@ -88,13 +139,19 @@ export class MessageProcessor { return p(text, uri, fileExtensions, graphqlFileExtensions, this._logger); }; this._tmpDir = tmpDir || tmpdir(); - - const tmpDirBase = path.join(this._tmpDir, 'graphql-language-service'); + 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(tmpDirBase)) { - mkdirp(tmpDirBase); + if (!existsSync(this._tmpDirBase)) { + mkdirp(this._tmpDirBase); } } get connection(): Connection { @@ -133,29 +190,14 @@ export class MessageProcessor { }, }; - this._sortedWorkspaceUris = params.workspaceFolders - ?.map(ws => ws.uri) - .sort((a, b) => b.length - a.length) ?? [ - URI.file( - configDir ? configDir.trim() : params.rootUri || this._rootPath, - ).toString(), - ]; - - this._sortedWorkspaceUris.forEach(uri => { - this._processors.set( - uri, - new WorkspaceMessageProcessor({ - connection: this._connection, - loadConfigOptions: this._loadConfigOptions, - logger: this._logger, - parser: this._parser, - tmpDir: this._tmpDir, - config: this._graphQLConfig, - rootPath: URI.parse(uri).fsPath, - }), + this._rootPath = configDir + ? configDir.trim() + : params.rootUri || this._rootPath; + if (!this._rootPath) { + this._logger.warn( + 'no rootPath configured in extension or server, defaulting to cwd', ); - }); - + } if (!serverCapabilities) { throw new Error('GraphQL Language Server is not initialized.'); } @@ -170,30 +212,208 @@ export class MessageProcessor { return serverCapabilities; } - _findWorkspaceProcessor(uri: string): WorkspaceMessageProcessor | undefined { - const workspace = this._sortedWorkspaceUris.find(wsUri => - uri.startsWith(wsUri), + async _updateGraphQLConfig() { + const settings = await this._connection.workspace.getConfiguration({ + section: 'graphql-config', + }); + const vscodeSettings = await this._connection.workspace.getConfiguration({ + section: 'vscode-graphql', + }); + if (settings?.dotEnvPath) { + require('dotenv').config({ path: settings.dotEnvPath }); + } + this._settings = { ...settings, ...vscodeSettings }; + const rootDir = this._settings?.load?.rootDir || this._rootPath; + this._rootPath = rootDir; + this._loadConfigOptions = { + ...Object.keys(this._settings?.load ?? {}).reduce((agg, key) => { + const value = this._settings?.load[key]; + if (value === undefined || value === null) { + delete agg[key]; + } + return agg; + }, this._settings.load ?? {}), + rootDir, + }; + try { + // reload the graphql cache + this._graphQLCache = await getGraphQLCache({ + parser: this._parser, + loadConfigOptions: this._loadConfigOptions, + + logger: this._logger, + }); + this._languageService = new GraphQLLanguageService( + this._graphQLCache, + this._logger, + ); + if (this._graphQLConfig || this._graphQLCache?.getGraphQLConfig) { + const config = + this._graphQLConfig ?? this._graphQLCache.getGraphQLConfig(); + await this._cacheAllProjectFiles(config); + } + this._isInitialized = true; + } catch (err) { + this._handleConfigError({ err }); + } + } + _handleConfigError({ err }: { err: unknown; uri?: string }) { + if (err instanceof ConfigNotFoundError) { + // TODO: obviously this needs to become a map by workspace from uri + // for workspaces support + this._isGraphQLConfigMissing = true; + + this._logConfigError(err.message); + return; + } else if (err instanceof ProjectNotFoundError) { + this._logConfigError( + 'Project not found for this file - make sure that a schema is present', + ); + } else if (err instanceof ConfigInvalidError) { + this._logConfigError(`Invalid configuration\n${err.message}`); + } else if (err instanceof ConfigEmptyError) { + this._logConfigError(err.message); + } else if (err instanceof LoaderNoResultError) { + this._logConfigError(err.message); + } else { + this._logConfigError( + // @ts-expect-error + err?.message ?? err?.toString(), + ); + } + + // force a no-op for all other language feature requests. + // + // TODO: contextually disable language features based on whether config is present + // Ideally could do this when sending initialization message, but extension settings are not available + // then, which are needed in order to initialize the language server (graphql-config loadConfig settings, for example) + this._isInitialized = false; + + // set this to false here so that if we don't have a missing config file issue anymore + // we can keep re-trying to load the config, so that on the next add or save event, + // it can keep retrying the language service + this._isGraphQLConfigMissing = false; + } + + _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}`, ); - return this._processors.get(workspace ?? ''); } 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 + */ + 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 + if (this._isGraphQLConfigMissing !== true) { + // then initial call to update graphql config + await this._updateGraphQLConfig(); + } else { + return null; + } + } + } catch (err) { + this._logger.error(String(err)); + } + + // Here, we set the workspace settings in memory, + // and re-initialize the language service when a different + // root path is detected. + // We aren't able to use initialization event for this + // and the config change event is after the fact. + if (!params || !params.textDocument) { throw new Error('`textDocument` argument is required.'); } - const { uri } = params.textDocument; - return ( - this._findWorkspaceProcessor(uri)?.handleDidOpenOrSaveNotification( - params, - ) ?? { uri, diagnostics: [] } - ); + const { textDocument } = params; + const { uri } = textDocument; + + const diagnostics: Diagnostic[] = []; + + let contents: CachedContent[] = []; + + // Create/modify the cached entry if text is provided. + // Otherwise, try searching the cache to perform diagnostics. + if ('text' in textDocument && textDocument.text) { + // textDocument/didSave does not pass in the text content. + // Only run the below function if text is passed in. + contents = this._parser(textDocument.text, uri); + + await this._invalidateCache(textDocument, uri, contents); + } else { + const configMatchers = [ + 'graphql.config', + 'graphqlrc', + 'package.json', + this._settings.load?.fileName, + ].filter(Boolean); + if (configMatchers.some(v => uri.match(v)?.length)) { + this._logger.info('updating graphql config'); + this._updateGraphQLConfig(); + return { uri, diagnostics: [] }; + } + // update graphql config only when graphql config is saved! + const cachedDocument = this._getCachedDocument(textDocument.uri); + if (cachedDocument) { + contents = cachedDocument.contents; + } + } + if (!this._graphQLCache) { + return { uri, diagnostics }; + } else { + 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), + ); + } + }), + ); + } + + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/didOpenOrSave', + projectName: project?.name, + fileName: uri, + }), + ); + } catch (err) { + this._handleConfigError({ err, uri }); + } + } + + return { uri, diagnostics }; } async handleDidChangeNotification( params: DidChangeTextDocumentParams, ): Promise { + if (!this._isInitialized || !this._graphQLCache) { + return null; + } // For every `textDocument/didChange` event, keep a cache of textDocuments // with version information up-to-date, so that the textDocument contents // may be used during performing language service features, @@ -208,21 +428,66 @@ export class MessageProcessor { '`textDocument`, `textDocument.uri`, and `contentChanges` arguments are required.', ); } - return ( - this._findWorkspaceProcessor( - params.textDocument.uri, - )?.handleDidChangeNotification(params) ?? null + + const textDocument = params.textDocument; + const contentChanges = params.contentChanges; + const contentChange = contentChanges[contentChanges.length - 1]; + + // As `contentChanges` is an array and we just want the + // latest update to the text, grab the last entry from the array. + const uri = textDocument.uri; + + // 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 project = this._graphQLCache.getProjectForFile(uri); + 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), + ); + } + }), + ); + } + + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/didChange', + projectName: project?.name, + fileName: uri, + }), ); - } + return { uri, diagnostics }; + } async handleDidChangeConfiguration( _params: DidChangeConfigurationParams, ): Promise { - await Promise.all( - Array.from(this._processors.values()).map(processor => - processor._updateGraphQLConfig(), - ), - ); + await this._updateGraphQLConfig(); this._logger.log( JSON.stringify({ type: 'usage', @@ -233,14 +498,31 @@ export class MessageProcessor { } handleDidCloseNotification(params: DidCloseTextDocumentParams): void { + if (!this._isInitialized || !this._graphQLCache) { + return; + } // For every `textDocument/didClose` event, delete the cached entry. // This is to keep a low memory usage && switch the source of truth to // the file on disk. if (!params || !params.textDocument) { throw new Error('`textDocument` is required.'); } - const { uri } = params.textDocument; - this._findWorkspaceProcessor(uri)?.handleDidCloseNotification(params); + const textDocument = params.textDocument; + const uri = textDocument.uri; + + if (this._textDocumentCache.has(uri)) { + this._textDocumentCache.delete(uri); + } + const project = this._graphQLCache.getProjectForFile(uri); + + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/didClose', + projectName: project?.name, + fileName: uri, + }), + ); } handleShutdownRequest(): void { @@ -268,37 +550,174 @@ export class MessageProcessor { async handleCompletionRequest( params: CompletionParams, ): Promise> { + if (!this._isInitialized || !this._graphQLCache) { + return []; + } + this.validateDocumentAndPosition(params); - return ( - this._findWorkspaceProcessor( - params.textDocument.uri, - )?.handleCompletionRequest(params) ?? [] + const textDocument = params.textDocument; + const position = params.position; + + // `textDocument/completion` event takes advantage of the fact that + // `textDocument/didChange` event always fires before, which would have + // updated the cache with the query text from the editor. + // Treat the computed list always complete. + + const cachedDocument = this._getCachedDocument(textDocument.uri); + if (!cachedDocument) { + return []; + } + + const found = cachedDocument.contents.find(content => { + const currentRange = content.range; + if (currentRange?.containsPosition(toPosition(position))) { + return true; + } + }); + + // If there is no GraphQL query in this file, return an empty result. + if (!found) { + return []; + } + + const { query, range } = found; + + if (range) { + position.line -= range.start.line; + } + const result = await this._languageService.getAutocompleteSuggestions( + query, + toPosition(position), + textDocument.uri, + ); + + const project = this._graphQLCache.getProjectForFile(textDocument.uri); + + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/completion', + projectName: project?.name, + fileName: textDocument.uri, + }), ); + + return { items: result, isIncomplete: false }; } async handleHoverRequest(params: TextDocumentPositionParams): Promise { + if (!this._isInitialized || !this._graphQLCache) { + return { contents: [] }; + } + this.validateDocumentAndPosition(params); - return ( - this._findWorkspaceProcessor(params.textDocument.uri)?.handleHoverRequest( - params, - ) ?? { - contents: [], + const textDocument = params.textDocument; + const position = params.position; + + const cachedDocument = this._getCachedDocument(textDocument.uri); + if (!cachedDocument) { + return { contents: [] }; + } + + const found = cachedDocument.contents.find(content => { + const currentRange = content.range; + if (currentRange?.containsPosition(toPosition(position))) { + return true; } + }); + + // If there is no GraphQL query in this file, return an empty result. + if (!found) { + return { contents: [] }; + } + + const { query, range } = found; + + if (range) { + position.line -= range.start.line; + } + const result = await this._languageService.getHoverInformation( + query, + toPosition(position), + textDocument.uri, + { useMarkdown: true }, ); + + return { + contents: result, + }; } async handleWatchedFilesChangedNotification( params: DidChangeWatchedFilesParams, ): Promise | null> { + if (!this._isInitialized || !this._graphQLCache) { + return null; + } + return Promise.all( params.changes.map(async (change: FileEvent) => { - return ( - this._findWorkspaceProcessor( + if (!this._isInitialized || !this._graphQLCache) { + throw Error('No cache available for handleWatchedFilesChanged'); + } else if ( + change.type === FileChangeTypeKind.Created || + change.type === FileChangeTypeKind.Changed + ) { + const uri = change.uri; + + 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); + + const project = this._graphQLCache.getProjectForFile(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); + } else { + return []; + } + }), + ) + ).reduce((left, right) => left.concat(right), diagnostics); + } + + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'workspace/didChangeWatchedFiles', + projectName: project?.name, + fileName: uri, + }), + ); + return { uri, diagnostics }; + } else if (change.type === FileChangeTypeKind.Deleted) { + this._graphQLCache.updateFragmentDefinitionCache( + this._graphQLCache.getGraphQLConfig().dirpath, change.uri, - )?.handleWatchedFileChangedNotification(change) ?? undefined - ); + false, + ); + this._graphQLCache.updateObjectTypeDefinitionCache( + this._graphQLCache.getGraphQLConfig().dirpath, + change.uri, + false, + ); + } }), ); } @@ -307,27 +726,121 @@ export class MessageProcessor { params: TextDocumentPositionParams, _token?: CancellationToken, ): Promise> { + if (!this._isInitialized || !this._graphQLCache) { + return []; + } + if (!params || !params.textDocument || !params.position) { throw new Error('`textDocument` and `position` arguments are required.'); } - return ( - this._findWorkspaceProcessor( - params.textDocument.uri, - )?.handleDefinitionRequest(params, _token) ?? [] + const textDocument = params.textDocument; + const position = params.position; + 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; + if (currentRange?.containsPosition(toPosition(position))) { + return true; + } + }); + + // If there is no GraphQL query in this file, return an empty result. + if (!found) { + return []; + } + + const { query, range: parentRange } = found; + if (parentRange) { + position.line -= parentRange.start.line; + } + + let result = null; + + try { + result = await this._languageService.getDefinition( + query, + toPosition(position), + textDocument.uri, + ); + } catch (err) { + // these thrown errors end up getting fired before the service is initialized, so lets cool down on that + } + + const inlineFragments: string[] = []; + try { + visit(parse(query), { + FragmentDefinition: (node: FragmentDefinitionNode) => { + inlineFragments.push(node.name.value); + }, + }); + } catch {} + + const formatted = result + ? result.definitions.map(res => { + const defRange = res.range as Range; + + if (parentRange && res.name) { + const isInline = inlineFragments.includes(res.name); + const isEmbedded = DEFAULT_SUPPORTED_EXTENSIONS.includes( + path.extname(textDocument.uri), + ); + if (isInline && isEmbedded) { + const vOffset = parentRange.start.line; + defRange.setStart( + (defRange.start.line += vOffset), + defRange.start.character, + ); + defRange.setEnd( + (defRange.end.line += vOffset), + defRange.end.character, + ); + } + } + return { + uri: res.path, + range: defRange, + } as Location; + }) + : []; + + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/definition', + projectName: project?.name, + fileName: textDocument.uri, + }), ); + return formatted; } async handleDocumentSymbolRequest( params: DocumentSymbolParams, ): Promise> { + if (!this._isInitialized || !this._graphQLCache) { + return []; + } + if (!params || !params.textDocument) { throw new Error('`textDocument` argument is required.'); } - return ( - this._findWorkspaceProcessor( - params.textDocument.uri, - )?.handleDocumentSymbolRequest(params) ?? [] + const textDocument = params.textDocument; + const cachedDocument = this._getCachedDocument(textDocument.uri); + if (!cachedDocument || !cachedDocument.contents[0]) { + return []; + } + + return this._languageService.getDocumentSymbols( + cachedDocument.contents[0].query, + textDocument.uri, ); } @@ -355,13 +868,369 @@ export class MessageProcessor { async handleWorkspaceSymbolRequest( params: WorkspaceSymbolParams, ): Promise> { + if (!this._isInitialized || !this._graphQLCache) { + return []; + } // const config = await this._graphQLCache.getGraphQLConfig(); // await this._cacheAllProjectFiles(config); - return Promise.all( - Array.from(this._processors.values()).flatMap(processor => - processor.handleWorkspaceSymbolRequest(params), + if (params.query !== '') { + const documents = this._getTextDocuments(); + const symbols: SymbolInformation[] = []; + 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, + uri, + ); + symbols.push(...docSymbols); + }), + ); + return symbols.filter( + symbol => symbol?.name && symbol.name.includes(params.query), + ); + } + + return []; + } + + _getTextDocuments() { + return Array.from(this._textDocumentCache); + } + + async _cacheSchemaText(uri: string, text: string, version: number) { + try { + const contents = this._parser(text, uri); + if (contents.length > 0) { + await this._invalidateCache({ version, uri }, uri, contents); + await this._updateObjectTypeDefinition(uri, contents); + } + } catch (err) { + this._logger.error(String(err)); + } + } + async _cacheSchemaFile( + uri: UnnormalizedTypeDefPointer, + project: GraphQLProjectConfig, + ) { + 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++; + } + const schemaText = readFileSync(uri, { encoding: 'utf-8' }); + this._cacheSchemaText(schemaUri, schemaText, version); + } + } + _getTmpProjectPath( + project: GraphQLProjectConfig, + prependWithProtocol = true, + appendPath?: string, + ) { + const baseDir = this._graphQLCache.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)) { + mkdirp(projectTmpPath); + } + if (appendPath) { + projectTmpPath = path.join(projectTmpPath, appendPath); + } + if (prependWithProtocol) { + return URI.file(path.resolve(projectTmpPath)).toString(); + } else { + 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 { + this._cacheSchemaFile(uri, project); + } catch (err) { + // this string may be an SDL string even, how do we even evaluate this? + } + } + } catch (err) {} + } + async _cacheObjectSchema( + pointer: { [key: string]: any }, + project: GraphQLProjectConfig, + ) { + await Promise.all( + Object.keys(pointer).map(async schemaUri => + this._cacheSchemaPath(schemaUri, project), ), - ).then(symbolsList => symbolsList.flat()); + ); } + 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; + const config = project?.extensions?.languageService; + /** + * By default, we look for schema definitions in SDL files + * + * with the opt-in feature `cacheSchemaOutputFileForLookup` enabled, + * the resultant `graphql-config` .getSchema() schema output will be cached + * locally and available as a single file for definition lookup and peek + * + * this is helpful when your `graphql-config` `schema` input is: + * - a remote or local URL + * - compiled from graphql files and code sources + * - otherwise where you don't have schema SDL in the codebase or don't want to use it for lookup + * + * it is disabled by default + */ + const cacheSchemaFileForLookup = + config?.cacheSchemaFileForLookup ?? + this?._settings?.cacheSchemaFileForLookup ?? + false; + if (cacheSchemaFileForLookup) { + 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); + } + } + /** + * Cache the schema as represented by graphql-config, with extensions + * from GraphQLCache.getSchema() + * @param project {GraphQLProjectConfig} + */ + async _cacheConfigSchema(project: GraphQLProjectConfig) { + try { + const schema = await this._graphQLCache.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); + + if (!cachedSchemaDoc) { + await writeFileAsync(fsPath, schemaText, { + encoding: 'utf-8', + }); + await this._cacheSchemaText(uri, schemaText, 1); + } + // do we have a change in the getSchema result? if so, update schema cache + if (cachedSchemaDoc) { + writeFileSync(fsPath, schemaText, { + encoding: 'utf-8', + }); + await this._cacheSchemaText( + uri, + schemaText, + cachedSchemaDoc.version++, + ); + } + } + } catch (err) { + this._logger.error(String(err)); + } + } + /** + * Pre-cache all documents for a project. + * + * TODO: Maybe make this optional, where only schema needs to be pre-cached. + * + * @param project {GraphQLProjectConfig} + */ + async _cacheDocumentFilesforProject(project: GraphQLProjectConfig) { + try { + const documents = await project.getDocuments(); + return Promise.all( + documents.map(async document => { + if (!document.location || !document.rawSDL) { + return; + } + + let filePath = document.location; + if (!path.isAbsolute(filePath)) { + filePath = path.join(project.dirpath, document.location); + } + + // build full system URI path with protocol + const uri = URI.file(filePath).toString(); + + // 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] || !contents[0].query) { + return; + } + await this._updateObjectTypeDefinition(uri, contents); + await this._updateFragmentDefinition(uri, contents); + await this._invalidateCache({ version: 1, uri }, uri, contents); + }), + ); + } catch (err) { + this._logger.error( + `invalid/unknown file in graphql config documents entry:\n '${project.documents}'`, + ); + this._logger.error(String(err)); + } + } + /** + * This should only be run on initialize() really. + * Caching all the document files upfront could be expensive. + * @param config {GraphQLConfig} + */ + 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); + }), + ); + } + } + _isRelayCompatMode(query: string): boolean { + return ( + query.indexOf('RelayCompat') !== -1 || + query.indexOf('react-relay/compat') !== -1 + ); + } + + async _updateFragmentDefinition( + uri: Uri, + contents: CachedContent[], + ): Promise { + const rootDir = this._graphQLCache.getGraphQLConfig().dirpath; + + await this._graphQLCache.updateFragmentDefinition(rootDir, uri, contents); + } + + async _updateObjectTypeDefinition( + uri: Uri, + contents: CachedContent[], + ): Promise { + const rootDir = this._graphQLCache.getGraphQLConfig().dirpath; + + await this._graphQLCache.updateObjectTypeDefinition(rootDir, uri, contents); + } + + _getCachedDocument(uri: string): CachedDocumentType | null { + if (this._textDocumentCache.has(uri)) { + const cachedDocument = this._textDocumentCache.get(uri); + if (cachedDocument) { + return cachedDocument; + } + } + + return null; + } + async _invalidateCache( + textDocument: VersionedTextDocumentIdentifier, + uri: Uri, + contents: CachedContent[], + ): Promise | null> { + if (this._textDocumentCache.has(uri)) { + const cachedDocument = this._textDocumentCache.get(uri); + if ( + cachedDocument && + textDocument && + textDocument?.version && + cachedDocument.version < textDocument.version + ) { + // Current server capabilities specify the full sync of the contents. + // Therefore always overwrite the entire content. + return this._textDocumentCache.set(uri, { + version: textDocument.version, + contents, + }); + } + } + return this._textDocumentCache.set(uri, { + version: textDocument.version ?? 0, + contents, + }); + } +} + +function processDiagnosticsMessage( + results: Diagnostic[], + query: string, + range: RangeType | null, +): Diagnostic[] { + const queryLines = query.split('\n'); + const totalLines = queryLines.length; + const lastLineLength = queryLines[totalLines - 1].length; + const lastCharacterPosition = new Position(totalLines, lastLineLength); + const processedResults = results.filter(diagnostic => + // @ts-ignore + diagnostic.range.end.lessThanOrEqualTo(lastCharacterPosition), + ); + + if (range) { + const offset = range.start; + return processedResults.map(diagnostic => ({ + ...diagnostic, + range: new Range( + new Position( + diagnostic.range.start.line + offset.line, + diagnostic.range.start.character, + ), + new Position( + diagnostic.range.end.line + offset.line, + diagnostic.range.end.character, + ), + ), + })); + } + + return processedResults; } diff --git a/packages/graphql-language-service-server/src/WorkspaceMessageProcessor.ts b/packages/graphql-language-service-server/src/WorkspaceMessageProcessor.ts deleted file mode 100644 index e0df95ac524..00000000000 --- a/packages/graphql-language-service-server/src/WorkspaceMessageProcessor.ts +++ /dev/null @@ -1,1114 +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 glob from 'fast-glob'; -import { existsSync, readFileSync, writeFile, writeFileSync } from 'fs'; -import { - CachedContent, - FileChangeTypeKind, - GraphQLConfig, - GraphQLProjectConfig, - IPosition, - Position, - Range, - Uri, -} from 'graphql-language-service'; -import mkdirp from 'mkdirp'; -import * as path from 'path'; -import { URI } from 'vscode-uri'; - -import { GraphQLLanguageService } from './GraphQLLanguageService'; - -import type { - CompletionParams, - DidOpenTextDocumentParams, - DidSaveTextDocumentParams, - FileEvent, - VersionedTextDocumentIdentifier, -} from 'vscode-languageserver/node'; - -import type { - CancellationToken, - CompletionItem, - CompletionList, - Connection, - Diagnostic, - DidChangeTextDocumentParams, - DidCloseTextDocumentParams, - DocumentSymbolParams, - Hover, - Location, - Position as VscodePosition, - PublishDiagnosticsParams, - Range as RangeType, - SymbolInformation, - TextDocumentPositionParams, - WorkspaceSymbolParams, -} from 'vscode-languageserver/node'; - -import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load'; - -import { getGraphQLCache, GraphQLCache } from './GraphQLCache'; -import { DEFAULT_SUPPORTED_EXTENSIONS } from './parseDocument'; - -import { FragmentDefinitionNode, parse, printSchema, visit } from 'graphql'; -import { - ConfigEmptyError, - ConfigInvalidError, - ConfigNotFoundError, - GraphQLExtensionDeclaration, - LoaderNoResultError, - ProjectNotFoundError, -} from 'graphql-config'; -import { promisify } from 'util'; -import { Logger } from './Logger'; -import type { LoadConfigOptions } from './types'; - -const writeFileAsync = promisify(writeFile); - -const configDocLink = - 'https://www.npmjs.com/package/graphql-language-service-server#user-content-graphql-configuration-file'; - -type CachedDocumentType = { - version: number; - contents: CachedContent[]; -}; -function toPosition(position: VscodePosition): IPosition { - return new Position(position.line, position.character); -} - -export class WorkspaceMessageProcessor { - _connection: Connection; - _graphQLCache!: GraphQLCache; - _graphQLConfig: GraphQLConfig | undefined; - _languageService!: GraphQLLanguageService; - _textDocumentCache: Map; - _isInitialized: boolean; - _isGraphQLConfigMissing: boolean | null = null; - _logger: Logger; - _extensions?: GraphQLExtensionDeclaration[]; - _parser: (text: string, uri: string) => CachedContent[]; - _tmpDir: string; - _tmpDirBase: string; - _loadConfigOptions: LoadConfigOptions; - _rootPath: string; - _settings: any; - - constructor({ - logger, - loadConfigOptions, - config, - parser, - tmpDir, - connection, - rootPath, - }: { - logger: Logger; - loadConfigOptions: LoadConfigOptions; - config?: GraphQLConfig; - parser: (text: string, uri: string) => CachedContent[]; - tmpDir: string; - connection: Connection; - rootPath: string; - }) { - this._connection = connection; - this._textDocumentCache = new Map(); - this._isInitialized = false; - this._logger = logger; - this._graphQLConfig = config; - this._parser = parser; - this._tmpDir = tmpDir; - this._tmpDirBase = path.join(this._tmpDir, 'graphql-language-service'); - // 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)) { - mkdirp(this._tmpDirBase); - } - this._rootPath = rootPath; - } - - async _updateGraphQLConfig() { - const settings = await this._connection.workspace.getConfiguration({ - section: 'graphql-config', - scopeUri: this._rootPath, - }); - const vscodeSettings = await this._connection.workspace.getConfiguration({ - section: 'vscode-graphql', - scopeUri: this._rootPath, - }); - if (settings?.dotEnvPath) { - require('dotenv').config({ path: settings.dotEnvPath }); - } - this._settings = { ...settings, ...vscodeSettings }; - const rootDir = this._settings?.load?.rootDir || this._rootPath; - this._rootPath = rootDir; - this._loadConfigOptions = { - ...Object.keys(this._settings?.load ?? {}).reduce((agg, key) => { - const value = this._settings?.load[key]; - if (value === undefined || value === null) { - delete agg[key]; - } - return agg; - }, this._settings.load ?? {}), - rootDir, - }; - try { - // reload the graphql cache - this._graphQLCache = await getGraphQLCache({ - parser: this._parser, - loadConfigOptions: this._loadConfigOptions, - - logger: this._logger, - }); - this._languageService = new GraphQLLanguageService( - this._graphQLCache, - this._logger, - ); - if (this._graphQLConfig || this._graphQLCache?.getGraphQLConfig) { - const config = - this._graphQLConfig ?? this._graphQLCache.getGraphQLConfig(); - await this._cacheAllProjectFiles(config); - } - this._isInitialized = true; - } catch (err) { - this._handleConfigError({ err }); - } - } - _handleConfigError({ err }: { err: unknown; uri?: string }) { - if (err instanceof ConfigNotFoundError) { - // TODO: obviously this needs to become a map by workspace from uri - // for workspaces support - this._isGraphQLConfigMissing = true; - - this._logConfigError(err.message); - return; - } else if (err instanceof ProjectNotFoundError) { - this._logConfigError( - 'Project not found for this file - make sure that a schema is present', - ); - } else if (err instanceof ConfigInvalidError) { - this._logConfigError(`Invalid configuration\n${err.message}`); - } else if (err instanceof ConfigEmptyError) { - this._logConfigError(err.message); - } else if (err instanceof LoaderNoResultError) { - this._logConfigError(err.message); - } else { - this._logConfigError( - // @ts-expect-error - err?.message ?? err?.toString(), - ); - } - - // force a no-op for all other language feature requests. - // - // TODO: contextually disable language features based on whether config is present - // Ideally could do this when sending initialization message, but extension settings are not available - // then, which are needed in order to initialize the language server (graphql-config loadConfig settings, for example) - this._isInitialized = false; - - // set this to false here so that if we don't have a missing config file issue anymore - // we can keep re-trying to load the config, so that on the next add or save event, - // it can keep retrying the language service - this._isGraphQLConfigMissing = false; - } - - _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 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 - */ - 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 - if (this._isGraphQLConfigMissing !== true) { - // then initial call to update graphql config - await this._updateGraphQLConfig(); - } else { - return null; - } - } - } catch (err) { - this._logger.error(String(err)); - } - - // Here, we set the workspace settings in memory, - // and re-initialize the language service when a different - // root path is detected. - // We aren't able to use initialization event for this - // and the config change event is after the fact. - - if (!params || !params.textDocument) { - throw new Error('`textDocument` argument is required.'); - } - const { textDocument } = params; - const { uri } = textDocument; - - const diagnostics: Diagnostic[] = []; - - let contents: CachedContent[] = []; - - // Create/modify the cached entry if text is provided. - // Otherwise, try searching the cache to perform diagnostics. - if ('text' in textDocument && textDocument.text) { - // textDocument/didSave does not pass in the text content. - // Only run the below function if text is passed in. - contents = this._parser(textDocument.text, uri); - - await this._invalidateCache(textDocument, uri, contents); - } else { - const configMatchers = [ - 'graphql.config', - 'graphqlrc', - 'package.json', - this._settings.load?.fileName, - ].filter(Boolean); - if (configMatchers.some(v => uri.match(v)?.length)) { - this._logger.info('updating graphql config'); - this._updateGraphQLConfig(); - return { uri, diagnostics: [] }; - } - // update graphql config only when graphql config is saved! - const cachedDocument = this._getCachedDocument(textDocument.uri); - if (cachedDocument) { - contents = cachedDocument.contents; - } - } - if (!this._graphQLCache) { - return { uri, diagnostics }; - } else { - 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), - ); - } - }), - ); - } - - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/didOpenOrSave', - projectName: project?.name, - fileName: uri, - }), - ); - } catch (err) { - this._handleConfigError({ err, uri }); - } - } - - return { uri, diagnostics }; - } - - async handleDidChangeNotification( - params: DidChangeTextDocumentParams, - ): Promise { - if (!this._isInitialized || !this._graphQLCache) { - return null; - } - // For every `textDocument/didChange` event, keep a cache of textDocuments - // with version information up-to-date, so that the textDocument contents - // may be used during performing language service features, - // e.g. auto-completions. - if ( - !params || - !params.textDocument || - !params.contentChanges || - !params.textDocument.uri - ) { - throw new Error( - '`textDocument`, `textDocument.uri`, and `contentChanges` arguments are required.', - ); - } - - const textDocument = params.textDocument; - const contentChanges = params.contentChanges; - const contentChange = contentChanges[contentChanges.length - 1]; - - // As `contentChanges` is an array and we just want the - // latest update to the text, grab the last entry from the array. - const uri = textDocument.uri; - - // 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 project = this._graphQLCache.getProjectForFile(uri); - 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), - ); - } - }), - ); - } - - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/didChange', - projectName: project?.name, - fileName: uri, - }), - ); - - return { uri, diagnostics }; - } - - handleDidCloseNotification(params: DidCloseTextDocumentParams): void { - if (!this._isInitialized || !this._graphQLCache) { - return; - } - // For every `textDocument/didClose` event, delete the cached entry. - // This is to keep a low memory usage && switch the source of truth to - // the file on disk. - if (!params || !params.textDocument) { - throw new Error('`textDocument` is required.'); - } - const textDocument = params.textDocument; - const uri = textDocument.uri; - - if (this._textDocumentCache.has(uri)) { - this._textDocumentCache.delete(uri); - } - const project = this._graphQLCache.getProjectForFile(uri); - - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/didClose', - projectName: project?.name, - fileName: uri, - }), - ); - } - - validateDocumentAndPosition(params: CompletionParams): void { - if ( - !params || - !params.textDocument || - !params.textDocument.uri || - !params.position - ) { - throw new Error( - '`textDocument`, `textDocument.uri`, and `position` arguments are required.', - ); - } - } - - async handleCompletionRequest( - params: CompletionParams, - ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { - return []; - } - - this.validateDocumentAndPosition(params); - - const textDocument = params.textDocument; - const position = params.position; - - // `textDocument/completion` event takes advantage of the fact that - // `textDocument/didChange` event always fires before, which would have - // updated the cache with the query text from the editor. - // Treat the computed list always complete. - - const cachedDocument = this._getCachedDocument(textDocument.uri); - if (!cachedDocument) { - return []; - } - - const found = cachedDocument.contents.find(content => { - const currentRange = content.range; - if (currentRange?.containsPosition(toPosition(position))) { - return true; - } - }); - - // If there is no GraphQL query in this file, return an empty result. - if (!found) { - return []; - } - - const { query, range } = found; - - if (range) { - position.line -= range.start.line; - } - const result = await this._languageService.getAutocompleteSuggestions( - query, - toPosition(position), - textDocument.uri, - ); - - const project = this._graphQLCache.getProjectForFile(textDocument.uri); - - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/completion', - projectName: project?.name, - fileName: textDocument.uri, - }), - ); - - return { items: result, isIncomplete: false }; - } - - async handleHoverRequest(params: TextDocumentPositionParams): Promise { - if (!this._isInitialized || !this._graphQLCache) { - return { contents: [] }; - } - - this.validateDocumentAndPosition(params); - - const textDocument = params.textDocument; - const position = params.position; - - const cachedDocument = this._getCachedDocument(textDocument.uri); - if (!cachedDocument) { - return { contents: [] }; - } - - const found = cachedDocument.contents.find(content => { - const currentRange = content.range; - if (currentRange?.containsPosition(toPosition(position))) { - return true; - } - }); - - // If there is no GraphQL query in this file, return an empty result. - if (!found) { - return { contents: [] }; - } - - const { query, range } = found; - - if (range) { - position.line -= range.start.line; - } - const result = await this._languageService.getHoverInformation( - query, - toPosition(position), - textDocument.uri, - { useMarkdown: true }, - ); - - return { - contents: result, - }; - } - - async handleWatchedFileChangedNotification( - change: FileEvent, - ): Promise { - if (!this._isInitialized || !this._graphQLCache) { - throw Error('No cache available for handleWatchedFilesChanged'); - } else if ( - change.type === FileChangeTypeKind.Created || - change.type === FileChangeTypeKind.Changed - ) { - const uri = change.uri; - - 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); - - const project = this._graphQLCache.getProjectForFile(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); - } else { - return []; - } - }), - ) - ).reduce((left, right) => left.concat(right), diagnostics); - } - - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'workspace/didChangeWatchedFiles', - projectName: project?.name, - fileName: uri, - }), - ); - return { uri, diagnostics }; - } else if (change.type === FileChangeTypeKind.Deleted) { - this._graphQLCache.updateFragmentDefinitionCache( - this._graphQLCache.getGraphQLConfig().dirpath, - change.uri, - false, - ); - this._graphQLCache.updateObjectTypeDefinitionCache( - this._graphQLCache.getGraphQLConfig().dirpath, - change.uri, - false, - ); - } - } - - async handleDefinitionRequest( - params: TextDocumentPositionParams, - _token?: CancellationToken, - ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { - return []; - } - - if (!params || !params.textDocument || !params.position) { - throw new Error('`textDocument` and `position` arguments are required.'); - } - const textDocument = params.textDocument; - const position = params.position; - 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; - if (currentRange?.containsPosition(toPosition(position))) { - return true; - } - }); - - // If there is no GraphQL query in this file, return an empty result. - if (!found) { - return []; - } - - const { query, range: parentRange } = found; - if (parentRange) { - position.line -= parentRange.start.line; - } - - let result = null; - - try { - result = await this._languageService.getDefinition( - query, - toPosition(position), - textDocument.uri, - ); - } catch (err) { - // these thrown errors end up getting fired before the service is initialized, so lets cool down on that - } - - const inlineFragments: string[] = []; - try { - visit(parse(query), { - FragmentDefinition: (node: FragmentDefinitionNode) => { - inlineFragments.push(node.name.value); - }, - }); - } catch {} - - const formatted = result - ? result.definitions.map(res => { - const defRange = res.range as Range; - - if (parentRange && res.name) { - const isInline = inlineFragments.includes(res.name); - const isEmbedded = DEFAULT_SUPPORTED_EXTENSIONS.includes( - path.extname(textDocument.uri), - ); - if (isInline && isEmbedded) { - const vOffset = parentRange.start.line; - defRange.setStart( - (defRange.start.line += vOffset), - defRange.start.character, - ); - defRange.setEnd( - (defRange.end.line += vOffset), - defRange.end.character, - ); - } - } - return { - uri: res.path, - range: defRange, - } as Location; - }) - : []; - - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/definition', - projectName: project?.name, - fileName: textDocument.uri, - }), - ); - return formatted; - } - - async handleDocumentSymbolRequest( - params: DocumentSymbolParams, - ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { - return []; - } - - if (!params || !params.textDocument) { - throw new Error('`textDocument` argument is required.'); - } - - const textDocument = params.textDocument; - const cachedDocument = this._getCachedDocument(textDocument.uri); - if (!cachedDocument || !cachedDocument.contents[0]) { - return []; - } - - return this._languageService.getDocumentSymbols( - cachedDocument.contents[0].query, - textDocument.uri, - ); - } - - async handleWorkspaceSymbolRequest( - params: WorkspaceSymbolParams, - ): Promise> { - if (!this._isInitialized || !this._graphQLCache) { - return []; - } - // const config = await this._graphQLCache.getGraphQLConfig(); - // await this._cacheAllProjectFiles(config); - - if (params.query !== '') { - const documents = this._getTextDocuments(); - const symbols: SymbolInformation[] = []; - 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, - uri, - ); - symbols.push(...docSymbols); - }), - ); - return symbols.filter( - symbol => symbol?.name && symbol.name.includes(params.query), - ); - } - - return []; - } - - _getTextDocuments() { - return Array.from(this._textDocumentCache); - } - - async _cacheSchemaText(uri: string, text: string, version: number) { - try { - const contents = this._parser(text, uri); - if (contents.length > 0) { - await this._invalidateCache({ version, uri }, uri, contents); - await this._updateObjectTypeDefinition(uri, contents); - } - } catch (err) { - this._logger.error(String(err)); - } - } - async _cacheSchemaFile( - uri: UnnormalizedTypeDefPointer, - project: GraphQLProjectConfig, - ) { - 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++; - } - const schemaText = readFileSync(uri, { encoding: 'utf-8' }); - this._cacheSchemaText(schemaUri, schemaText, version); - } - } - _getTmpProjectPath( - project: GraphQLProjectConfig, - prependWithProtocol = true, - appendPath?: string, - ) { - const baseDir = this._graphQLCache.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)) { - mkdirp(projectTmpPath); - } - if (appendPath) { - projectTmpPath = path.join(projectTmpPath, appendPath); - } - if (prependWithProtocol) { - return URI.file(path.resolve(projectTmpPath)).toString(); - } else { - 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 { - this._cacheSchemaFile(uri, project); - } catch (err) { - // this string may be an SDL string even, how do we even evaluate this? - } - } - } catch (err) {} - } - 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; - const config = project?.extensions?.languageService; - /** - * By default, we look for schema definitions in SDL files - * - * with the opt-in feature `cacheSchemaOutputFileForLookup` enabled, - * the resultant `graphql-config` .getSchema() schema output will be cached - * locally and available as a single file for definition lookup and peek - * - * this is helpful when your `graphql-config` `schema` input is: - * - a remote or local URL - * - compiled from graphql files and code sources - * - otherwise where you don't have schema SDL in the codebase or don't want to use it for lookup - * - * it is disabled by default - */ - const cacheSchemaFileForLookup = - config?.cacheSchemaFileForLookup ?? - this?._settings?.cacheSchemaFileForLookup ?? - false; - if (cacheSchemaFileForLookup) { - 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); - } - } - /** - * Cache the schema as represented by graphql-config, with extensions - * from GraphQLCache.getSchema() - * @param project {GraphQLProjectConfig} - */ - async _cacheConfigSchema(project: GraphQLProjectConfig) { - try { - const schema = await this._graphQLCache.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); - - if (!cachedSchemaDoc) { - await writeFileAsync(fsPath, schemaText, { - encoding: 'utf-8', - }); - await this._cacheSchemaText(uri, schemaText, 1); - } - // do we have a change in the getSchema result? if so, update schema cache - if (cachedSchemaDoc) { - writeFileSync(fsPath, schemaText, { - encoding: 'utf-8', - }); - await this._cacheSchemaText( - uri, - schemaText, - cachedSchemaDoc.version++, - ); - } - } - } catch (err) { - this._logger.error(String(err)); - } - } - /** - * Pre-cache all documents for a project. - * - * TODO: Maybe make this optional, where only schema needs to be pre-cached. - * - * @param project {GraphQLProjectConfig} - */ - async _cacheDocumentFilesforProject(project: GraphQLProjectConfig) { - try { - const documents = await project.getDocuments(); - return Promise.all( - documents.map(async document => { - if (!document.location || !document.rawSDL) { - return; - } - - let filePath = document.location; - if (!path.isAbsolute(filePath)) { - filePath = path.join(project.dirpath, document.location); - } - - // build full system URI path with protocol - const uri = URI.file(filePath).toString(); - - // 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] || !contents[0].query) { - return; - } - await this._updateObjectTypeDefinition(uri, contents); - await this._updateFragmentDefinition(uri, contents); - await this._invalidateCache({ version: 1, uri }, uri, contents); - }), - ); - } catch (err) { - this._logger.error( - `invalid/unknown file in graphql config documents entry:\n '${project.documents}'`, - ); - this._logger.error(String(err)); - } - } - /** - * This should only be run on initialize() really. - * Caching all the document files upfront could be expensive. - * @param config {GraphQLConfig} - */ - 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); - }), - ); - } - } - _isRelayCompatMode(query: string): boolean { - return ( - query.indexOf('RelayCompat') !== -1 || - query.indexOf('react-relay/compat') !== -1 - ); - } - - async _updateFragmentDefinition( - uri: Uri, - contents: CachedContent[], - ): Promise { - const rootDir = this._graphQLCache.getGraphQLConfig().dirpath; - - await this._graphQLCache.updateFragmentDefinition(rootDir, uri, contents); - } - - async _updateObjectTypeDefinition( - uri: Uri, - contents: CachedContent[], - ): Promise { - const rootDir = this._graphQLCache.getGraphQLConfig().dirpath; - - await this._graphQLCache.updateObjectTypeDefinition(rootDir, uri, contents); - } - - _getCachedDocument(uri: string): CachedDocumentType | null { - if (this._textDocumentCache.has(uri)) { - const cachedDocument = this._textDocumentCache.get(uri); - if (cachedDocument) { - return cachedDocument; - } - } - - return null; - } - async _invalidateCache( - textDocument: VersionedTextDocumentIdentifier, - uri: Uri, - contents: CachedContent[], - ): Promise | null> { - if (this._textDocumentCache.has(uri)) { - const cachedDocument = this._textDocumentCache.get(uri); - if ( - cachedDocument && - textDocument && - textDocument?.version && - cachedDocument.version < textDocument.version - ) { - // Current server capabilities specify the full sync of the contents. - // Therefore always overwrite the entire content. - return this._textDocumentCache.set(uri, { - version: textDocument.version, - contents, - }); - } - } - return this._textDocumentCache.set(uri, { - version: textDocument.version ?? 0, - contents, - }); - } -} - -function processDiagnosticsMessage( - results: Diagnostic[], - query: string, - range: RangeType | null, -): Diagnostic[] { - const queryLines = query.split('\n'); - const totalLines = queryLines.length; - const lastLineLength = queryLines[totalLines - 1].length; - const lastCharacterPosition = new Position(totalLines, lastLineLength); - const processedResults = results.filter(diagnostic => - // @ts-ignore - diagnostic.range.end.lessThanOrEqualTo(lastCharacterPosition), - ); - - if (range) { - const offset = range.start; - return processedResults.map(diagnostic => ({ - ...diagnostic, - range: new Range( - new Position( - diagnostic.range.start.line + offset.line, - diagnostic.range.start.character, - ), - new Position( - diagnostic.range.end.line + offset.line, - diagnostic.range.end.character, - ), - ), - })); - } - - return processedResults; -} diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts index 7ba9c06bb77..98cb5e2e3ae 100644 --- a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts @@ -6,10 +6,10 @@ * LICENSE file in the root directory of this source tree. * */ -import { Position, Range } from 'graphql-language-service'; import { tmpdir } from 'os'; 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'; @@ -22,9 +22,8 @@ import { loadConfig } from 'graphql-config'; import type { DefinitionQueryResult, Outline } from 'graphql-language-service'; -import { pathToFileURL } from 'url'; import { Logger } from '../Logger'; -import { WorkspaceMessageProcessor } from '../WorkspaceMessageProcessor'; +import { pathToFileURL } from 'url'; jest.mock('fs', () => ({ ...jest.requireActual('fs'), @@ -50,33 +49,16 @@ describe('MessageProcessor', () => { } `; - const workspaceMessageProcessor = new WorkspaceMessageProcessor({ - // @ts-ignore - connection: {}, - loadConfigOptions: { rootDir: __dirname }, - logger, - parser: messageProcessor._parser, - tmpDir: messageProcessor._tmpDir, - rootPath: __dirname, - }); - beforeEach(async () => { const gqlConfig = await loadConfig({ rootDir: __dirname, extensions: [] }); // loadConfig.mockRestore(); - const workspaceUri = pathToFileURL('.').toString(); - messageProcessor._sortedWorkspaceUris = [workspaceUri]; - - messageProcessor._processors = new Map([ - [workspaceUri, workspaceMessageProcessor], - ]); - workspaceMessageProcessor._graphQLConfig = gqlConfig; - workspaceMessageProcessor._settings = { load: {} }; - workspaceMessageProcessor._graphQLCache = new GraphQLCache({ + messageProcessor._settings = { load: {} }; + messageProcessor._graphQLCache = new GraphQLCache({ configDir: __dirname, config: gqlConfig, parser: parseDocument, }); - workspaceMessageProcessor._languageService = { + messageProcessor._languageService = { // @ts-ignore getAutocompleteSuggestions: (query, position, uri) => { return [{ label: `${query} at ${uri}` }]; @@ -133,7 +115,7 @@ describe('MessageProcessor', () => { let getConfigurationReturnValue = {}; // @ts-ignore - workspaceMessageProcessor._connection = { + messageProcessor._connection = { // @ts-ignore get workspace() { return { @@ -152,7 +134,7 @@ describe('MessageProcessor', () => { }, }; - workspaceMessageProcessor._isInitialized = true; + messageProcessor._isInitialized = true; it('initializes properly and opens a file', async () => { const { capabilities } = await messageProcessor.handleInitializeRequest( @@ -172,7 +154,7 @@ describe('MessageProcessor', () => { it('runs completion requests properly', async () => { const uri = `${queryPathUri}/test2.graphql`; const query = 'test'; - workspaceMessageProcessor._textDocumentCache.set(uri, { + messageProcessor._textDocumentCache.set(uri, { version: 0, contents: [ { @@ -211,7 +193,7 @@ describe('MessageProcessor', () => { }, }; - workspaceMessageProcessor._textDocumentCache.set(uri, { + messageProcessor._textDocumentCache.set(uri, { version: 0, contents: [ { @@ -239,7 +221,7 @@ describe('MessageProcessor', () => { it('properly changes the file cache with the didChange handler', async () => { const uri = `${queryPathUri}/test.graphql`; - workspaceMessageProcessor._textDocumentCache.set(uri, { + messageProcessor._textDocumentCache.set(uri, { version: 1, contents: [ { @@ -312,7 +294,7 @@ describe('MessageProcessor', () => { version: 1, }, }; - workspaceMessageProcessor._getCachedDocument = (_uri: string) => ({ + messageProcessor._getCachedDocument = (_uri: string) => ({ version: 1, contents: [ { @@ -338,7 +320,7 @@ describe('MessageProcessor', () => { beforeEach(() => { mockReadFileSync.mockReturnValue(''); - workspaceMessageProcessor._updateGraphQLConfig = jest.fn(); + messageProcessor._updateGraphQLConfig = jest.fn(); }); it('updates config for standard config filename changes', async () => { await messageProcessor.handleDidOpenOrSaveNotification({ @@ -350,14 +332,12 @@ describe('MessageProcessor', () => { }, }); - expect(workspaceMessageProcessor._updateGraphQLConfig).toHaveBeenCalled(); + expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); }); it('updates config for custom config filename changes', async () => { const customConfigName = 'custom-config-name.yml'; - workspaceMessageProcessor._settings = { - load: { fileName: customConfigName }, - }; + messageProcessor._settings = { load: { fileName: customConfigName } }; await messageProcessor.handleDidOpenOrSaveNotification({ textDocument: { @@ -368,17 +348,17 @@ describe('MessageProcessor', () => { }, }); - expect(workspaceMessageProcessor._updateGraphQLConfig).toHaveBeenCalled(); + expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); }); it('handles config requests with no config', async () => { - workspaceMessageProcessor._settings = {}; + messageProcessor._settings = {}; await messageProcessor.handleDidChangeConfiguration({ settings: [], }); - expect(workspaceMessageProcessor._updateGraphQLConfig).toHaveBeenCalled(); + expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); await messageProcessor.handleDidOpenOrSaveNotification({ textDocument: { @@ -389,7 +369,7 @@ describe('MessageProcessor', () => { }, }); - expect(workspaceMessageProcessor._updateGraphQLConfig).toHaveBeenCalled(); + expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); }); }); diff --git a/packages/vscode-graphql/package.json b/packages/vscode-graphql/package.json index df8f676b94d..2e09120e523 100644 --- a/packages/vscode-graphql/package.json +++ b/packages/vscode-graphql/package.json @@ -98,55 +98,48 @@ "type": [ "boolean" ], - "description": "Use a cached file output of your graphql-config schema result for definition lookups, symbols, outline, etc. Disabled by default.", - "scope": "resource" + "description": "Use a cached file output of your graphql-config schema result for definition lookups, symbols, outline, etc. Disabled by default." }, "vscode-graphql.rejectUnauthorized": { "type": [ "boolean" ], "description": "Fail the request on invalid certificate", - "default": true, - "scope": "resource" + "default": true }, "graphql-config.load.rootDir": { "type": [ "string" ], - "description": "Base dir for graphql config loadConfig()", - "scope": "resource" + "description": "Base dir for graphql config loadConfig()" }, "graphql-config.load.filePath": { "type": [ "string" ], "description": "filePath for graphql config loadConfig()", - "default": null, - "scope": "resource" + "default": null }, "graphql-config.load.legacy": { "type": [ "boolean" ], "description": "legacy mode for graphql config v2 config", - "default": null, - "scope": "resource" + "default": null }, "graphql-config.load.configName": { "type": [ "string" ], "description": "optional .config.js instead of default `graphql`", - "default": null, - "scope": "resource" + "default": null }, "graphql-config.dotEnvPath": { "type": [ "string" ], "description": "optional .env load path, if not the default", - "default": null, - "scope": "resource" + "default": null } } },