diff --git a/.changeset/thick-years-sort.md b/.changeset/thick-years-sort.md new file mode 100644 index 00000000000..412c58fc5bc --- /dev/null +++ b/.changeset/thick-years-sort.md @@ -0,0 +1,9 @@ +--- +"vscode-graphql": patch +"graphql-language-service-server": patch +"graphql-language-service-cli": patch +--- + +support vscode multi-root workspaces! creates an LSP server instance for each workspace. + +WARNING: large-scale vscode workspaces usage, and this in tandem with `graphql.config.*` multi-project configs could lead to excessive system resource usage. Optimizations coming soon. diff --git a/packages/graphql-language-service-server/src/MessageProcessor.ts b/packages/graphql-language-service-server/src/MessageProcessor.ts index af8676e31f5..25a5bba8b68 100644 --- a/packages/graphql-language-service-server/src/MessageProcessor.ts +++ b/packages/graphql-language-service-server/src/MessageProcessor.ts @@ -7,107 +7,58 @@ * */ +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, - FileEvent, - VersionedTextDocumentIdentifier, - DidSaveTextDocumentParams, - DidOpenTextDocumentParams, DidChangeConfigurationParams, + DidOpenTextDocumentParams, + DidSaveTextDocumentParams, + FileEvent, } from 'vscode-languageserver/node'; import type { - Diagnostic, + CancellationToken, CompletionItem, CompletionList, - CancellationToken, + Connection, + DidChangeConfigurationRegistrationOptions, + DidChangeTextDocumentParams, + DidChangeWatchedFilesParams, + DidCloseTextDocumentParams, + DocumentSymbolParams, Hover, + InitializeParams, InitializeResult, Location, PublishDiagnosticsParams, - DidChangeTextDocumentParams, - DidCloseTextDocumentParams, - DidChangeWatchedFilesParams, - InitializeParams, - Range as RangeType, - Position as VscodePosition, - TextDocumentPositionParams, - DocumentSymbolParams, SymbolInformation, + TextDocumentPositionParams, WorkspaceSymbolParams, - Connection, - DidChangeConfigurationRegistrationOptions, } from 'vscode-languageserver/node'; -import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load'; +import { parseDocument } from './parseDocument'; -import { getGraphQLCache, GraphQLCache } from './GraphQLCache'; -import { parseDocument, DEFAULT_SUPPORTED_EXTENSIONS } from './parseDocument'; - -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 { Logger } from './Logger'; import type { LoadConfigOptions } from './types'; -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); -} +import { WorkspaceMessageProcessor } from './WorkspaceMessageProcessor'; 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(); - _settings: any; + _sortedWorkspaceUris: string[] = []; + _processors: Map = new Map(); constructor({ logger, @@ -129,8 +80,6 @@ export class MessageProcessor { connection: Connection; }) { this._connection = connection; - this._textDocumentCache = new Map(); - this._isInitialized = false; this._willShutdown = false; this._logger = logger; this._graphQLConfig = config; @@ -139,19 +88,13 @@ export class MessageProcessor { 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(); + + const 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); + if (!existsSync(tmpDirBase)) { + mkdirp(tmpDirBase); } } get connection(): Connection { @@ -190,14 +133,29 @@ export class MessageProcessor { }, }; - 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', + 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, + }), ); - } + }); + if (!serverCapabilities) { throw new Error('GraphQL Language Server is not initialized.'); } @@ -212,208 +170,30 @@ export class MessageProcessor { return serverCapabilities; } - 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}`, + _findWorkspaceProcessor(uri: string): WorkspaceMessageProcessor | undefined { + const workspace = this._sortedWorkspaceUris.find(wsUri => + uri.startsWith(wsUri), ); + 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 { 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 }; + const { uri } = params.textDocument; + return ( + this._findWorkspaceProcessor(uri)?.handleDidOpenOrSaveNotification( + params, + ) ?? { 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, @@ -428,66 +208,21 @@ export class MessageProcessor { '`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 ( + this._findWorkspaceProcessor( + params.textDocument.uri, + )?.handleDidChangeNotification(params) ?? null ); - - return { uri, diagnostics }; } + async handleDidChangeConfiguration( _params: DidChangeConfigurationParams, ): Promise { - await this._updateGraphQLConfig(); + await Promise.all( + Array.from(this._processors.values()).map(processor => + processor._updateGraphQLConfig(), + ), + ); this._logger.log( JSON.stringify({ type: 'usage', @@ -498,31 +233,14 @@ 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 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, - }), - ); + const { uri } = params.textDocument; + this._findWorkspaceProcessor(uri)?.handleDidCloseNotification(params); } handleShutdownRequest(): void { @@ -550,174 +268,37 @@ export class MessageProcessor { 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 ( + this._findWorkspaceProcessor( + params.textDocument.uri, + )?.handleCompletionRequest(params) ?? [] ); - - 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; + return ( + this._findWorkspaceProcessor(params.textDocument.uri)?.handleHoverRequest( + params, + ) ?? { + contents: [], } - }); - - // 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) => { - 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, + return ( + this._findWorkspaceProcessor( change.uri, - false, - ); - } + )?.handleWatchedFileChangedNotification(change) ?? undefined + ); }), ); } @@ -726,121 +307,27 @@ 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.'); } - 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 ( + this._findWorkspaceProcessor( + params.textDocument.uri, + )?.handleDefinitionRequest(params, _token) ?? [] ); - 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, + return ( + this._findWorkspaceProcessor( + params.textDocument.uri, + )?.handleDocumentSymbolRequest(params) ?? [] ); } @@ -868,369 +355,13 @@ export class MessageProcessor { 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 Promise.all( + Array.from(this._processors.values()).flatMap(processor => + processor.handleWorkspaceSymbolRequest(params), ), - })); + ).then(symbolsList => symbolsList.flat()); } - - return processedResults; } diff --git a/packages/graphql-language-service-server/src/WorkspaceMessageProcessor.ts b/packages/graphql-language-service-server/src/WorkspaceMessageProcessor.ts new file mode 100644 index 00000000000..e0df95ac524 --- /dev/null +++ b/packages/graphql-language-service-server/src/WorkspaceMessageProcessor.ts @@ -0,0 +1,1114 @@ +/** + * 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 98cb5e2e3ae..7ba9c06bb77 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,8 +22,9 @@ import { loadConfig } from 'graphql-config'; import type { DefinitionQueryResult, Outline } from 'graphql-language-service'; -import { Logger } from '../Logger'; import { pathToFileURL } from 'url'; +import { Logger } from '../Logger'; +import { WorkspaceMessageProcessor } from '../WorkspaceMessageProcessor'; jest.mock('fs', () => ({ ...jest.requireActual('fs'), @@ -49,16 +50,33 @@ 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(); - messageProcessor._settings = { load: {} }; - messageProcessor._graphQLCache = new GraphQLCache({ + const workspaceUri = pathToFileURL('.').toString(); + messageProcessor._sortedWorkspaceUris = [workspaceUri]; + + messageProcessor._processors = new Map([ + [workspaceUri, workspaceMessageProcessor], + ]); + workspaceMessageProcessor._graphQLConfig = gqlConfig; + workspaceMessageProcessor._settings = { load: {} }; + workspaceMessageProcessor._graphQLCache = new GraphQLCache({ configDir: __dirname, config: gqlConfig, parser: parseDocument, }); - messageProcessor._languageService = { + workspaceMessageProcessor._languageService = { // @ts-ignore getAutocompleteSuggestions: (query, position, uri) => { return [{ label: `${query} at ${uri}` }]; @@ -115,7 +133,7 @@ describe('MessageProcessor', () => { let getConfigurationReturnValue = {}; // @ts-ignore - messageProcessor._connection = { + workspaceMessageProcessor._connection = { // @ts-ignore get workspace() { return { @@ -134,7 +152,7 @@ describe('MessageProcessor', () => { }, }; - messageProcessor._isInitialized = true; + workspaceMessageProcessor._isInitialized = true; it('initializes properly and opens a file', async () => { const { capabilities } = await messageProcessor.handleInitializeRequest( @@ -154,7 +172,7 @@ describe('MessageProcessor', () => { it('runs completion requests properly', async () => { const uri = `${queryPathUri}/test2.graphql`; const query = 'test'; - messageProcessor._textDocumentCache.set(uri, { + workspaceMessageProcessor._textDocumentCache.set(uri, { version: 0, contents: [ { @@ -193,7 +211,7 @@ describe('MessageProcessor', () => { }, }; - messageProcessor._textDocumentCache.set(uri, { + workspaceMessageProcessor._textDocumentCache.set(uri, { version: 0, contents: [ { @@ -221,7 +239,7 @@ describe('MessageProcessor', () => { it('properly changes the file cache with the didChange handler', async () => { const uri = `${queryPathUri}/test.graphql`; - messageProcessor._textDocumentCache.set(uri, { + workspaceMessageProcessor._textDocumentCache.set(uri, { version: 1, contents: [ { @@ -294,7 +312,7 @@ describe('MessageProcessor', () => { version: 1, }, }; - messageProcessor._getCachedDocument = (_uri: string) => ({ + workspaceMessageProcessor._getCachedDocument = (_uri: string) => ({ version: 1, contents: [ { @@ -320,7 +338,7 @@ describe('MessageProcessor', () => { beforeEach(() => { mockReadFileSync.mockReturnValue(''); - messageProcessor._updateGraphQLConfig = jest.fn(); + workspaceMessageProcessor._updateGraphQLConfig = jest.fn(); }); it('updates config for standard config filename changes', async () => { await messageProcessor.handleDidOpenOrSaveNotification({ @@ -332,12 +350,14 @@ describe('MessageProcessor', () => { }, }); - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); + expect(workspaceMessageProcessor._updateGraphQLConfig).toHaveBeenCalled(); }); it('updates config for custom config filename changes', async () => { const customConfigName = 'custom-config-name.yml'; - messageProcessor._settings = { load: { fileName: customConfigName } }; + workspaceMessageProcessor._settings = { + load: { fileName: customConfigName }, + }; await messageProcessor.handleDidOpenOrSaveNotification({ textDocument: { @@ -348,17 +368,17 @@ describe('MessageProcessor', () => { }, }); - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); + expect(workspaceMessageProcessor._updateGraphQLConfig).toHaveBeenCalled(); }); it('handles config requests with no config', async () => { - messageProcessor._settings = {}; + workspaceMessageProcessor._settings = {}; await messageProcessor.handleDidChangeConfiguration({ settings: [], }); - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); + expect(workspaceMessageProcessor._updateGraphQLConfig).toHaveBeenCalled(); await messageProcessor.handleDidOpenOrSaveNotification({ textDocument: { @@ -369,7 +389,7 @@ describe('MessageProcessor', () => { }, }); - expect(messageProcessor._updateGraphQLConfig).toHaveBeenCalled(); + expect(workspaceMessageProcessor._updateGraphQLConfig).toHaveBeenCalled(); }); }); diff --git a/packages/vscode-graphql/package.json b/packages/vscode-graphql/package.json index 67097c9b030..980003544b4 100644 --- a/packages/vscode-graphql/package.json +++ b/packages/vscode-graphql/package.json @@ -98,48 +98,55 @@ "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. Disabled by default.", + "scope": "resource" }, "vscode-graphql.rejectUnauthorized": { "type": [ "boolean" ], "description": "Fail the request on invalid certificate", - "default": true + "default": true, + "scope": "resource" }, "graphql-config.load.rootDir": { "type": [ "string" ], - "description": "Base dir for graphql config loadConfig()" + "description": "Base dir for graphql config loadConfig()", + "scope": "resource" }, "graphql-config.load.filePath": { "type": [ "string" ], "description": "filePath for graphql config loadConfig()", - "default": null + "default": null, + "scope": "resource" }, "graphql-config.load.legacy": { "type": [ "boolean" ], "description": "legacy mode for graphql config v2 config", - "default": null + "default": null, + "scope": "resource" }, "graphql-config.load.configName": { "type": [ "string" ], "description": "optional .config.js instead of default `graphql`", - "default": null + "default": null, + "scope": "resource" }, "graphql-config.dotEnvPath": { "type": [ "string" ], "description": "optional .env load path, if not the default", - "default": null + "default": null, + "scope": "resource" } } },