diff --git a/packages/graphql-language-service-server/README.md b/packages/graphql-language-service-server/README.md index d0f5388cd9a..2a02c3bce2b 100644 --- a/packages/graphql-language-service-server/README.md +++ b/packages/graphql-language-service-server/README.md @@ -29,6 +29,7 @@ Supported features include: - Support for `gql` `graphql` and other template tags inside javascript, typescript, jsx, ts, vue and svelte files, and an interface to allow custom parsing of all files. +- Support multiple GraphQL APIs in the same file via annotation suffixes. ## Installation and Usage @@ -187,6 +188,9 @@ export default { languageService: { cacheSchemaFileForLookup: true, enableValidation: false, + gqlTagOptions: { + annotationSuffix: 'my-project', + }, }, }, }, @@ -237,18 +241,37 @@ via `initializationOptions` in nvim.coc. The options are mostly designed to configure graphql-config's load parameters, the only thing we can't configure with graphql config. The final option can be set in `graphql-config` as well -| Parameter | Default | Description | -| ----------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `graphql-config.load.baseDir` | workspace root or process.cwd() | the path where graphql config looks for config files | -| `graphql-config.load.filePath` | `null` | exact filepath of the config file. | -| `graphql-config.load.configName` | `graphql` | config name prefix instead of `graphql` | -| `graphql-config.load.legacy` | `true` | backwards compatibility with `graphql-config@2` | -| `graphql-config.dotEnvPath` | `null` | backwards compatibility with `graphql-config@2` | -| `vscode-graphql.cacheSchemaFileForLookup` | `false` | generate an SDL file based on your graphql-config schema configuration for schema definition lookup and other features. useful when your `schema` config are urls | +| Parameter | Default | Description | +| ----------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `graphql-config.load.baseDir` | workspace root or process.cwd() | the path where graphql config looks for config files | +| `graphql-config.load.filePath` | `null` | exact filepath of the config file. | +| `graphql-config.load.configName` | `graphql` | config name prefix instead of `graphql` | +| `graphql-config.load.legacy` | `true` | backwards compatibility with `graphql-config@2` | +| `graphql-config.dotEnvPath` | `null` | backwards compatibility with `graphql-config@2` | +| `vscode-graphql.cacheSchemaFileForLookup` | `false` | generate an SDL file based on your graphql-config schema configuration for schema definition lookup and other features. useful when your `schema` config are urls | +| `vscode-graphql.gqlTagOptions.annotationSuffix` | `null` | establish a suffix to match queries to a project schema using `#graphql:` comment. Only the first matching project for a given query is used, thus supporting multiple queries for different schemas in the same file | all the `graphql-config.load.*` configuration values come from static `loadConfig()` options in graphql config. +Use the `gqlTagOptions.annotationSuffix` option to mix queries for different schemas in the same file. Each query annotated with the `#graphql:` comment will be matched to the first project with the same suffix: + +```ts +// file.js + +const queryForDefaultProject = `#graphql + query { something } +`; + +const queryForDbProject = `#graphql:db + query { something } +`; + +const queryForCmsProject = `#graphql:cms + query { something } +`; +``` + (more coming soon!) ### Architectural Overview diff --git a/packages/graphql-language-service-server/src/GraphQLCache.ts b/packages/graphql-language-service-server/src/GraphQLCache.ts index f7b043e5676..9388c1bd03a 100644 --- a/packages/graphql-language-service-server/src/GraphQLCache.ts +++ b/packages/graphql-language-service-server/src/GraphQLCache.ts @@ -47,6 +47,7 @@ import glob from 'glob'; import { LoadConfigOptions } from './types'; import { URI } from 'vscode-uri'; import { CodeFileLoader } from '@graphql-tools/code-file-loader'; +import { EXTENSION_NAME } from './GraphQLLanguageService'; import { DEFAULT_SUPPORTED_EXTENSIONS, DEFAULT_SUPPORTED_GRAPHQL_EXTENSIONS, @@ -59,7 +60,7 @@ const LanguageServiceExtension: GraphQLExtensionDeclaration = api => { // For documents api.loaders.documents.register(new CodeFileLoader()); - return { name: 'languageService' }; + return { name: EXTENSION_NAME }; }; // Maximum files to read when processing GraphQL files. diff --git a/packages/graphql-language-service-server/src/GraphQLLanguageService.ts b/packages/graphql-language-service-server/src/GraphQLLanguageService.ts index aeaa4c92a8e..129dbbb7d7f 100644 --- a/packages/graphql-language-service-server/src/GraphQLLanguageService.ts +++ b/packages/graphql-language-service-server/src/GraphQLLanguageService.ts @@ -56,6 +56,10 @@ import { SymbolKind, } from 'vscode-languageserver-types'; +import { fileURLToPath } from 'node:url'; + +export const EXTENSION_NAME = 'languageService'; + const KIND_TO_SYMBOL_KIND: { [key: string]: SymbolKind } = { [Kind.FIELD]: SymbolKind.Field, [Kind.OPERATION_DEFINITION]: SymbolKind.Class, @@ -84,6 +88,10 @@ function getKind(tree: OutlineTree) { return KIND_TO_SYMBOL_KIND[tree.kind]; } +function normalizeUri(uri: string) { + return uri.startsWith('file:') ? fileURLToPath(uri) : uri; +} + export class GraphQLLanguageService { _graphQLCache: GraphQLCache; _graphQLConfig: GraphQLConfig; @@ -96,11 +104,41 @@ export class GraphQLLanguageService { this._logger = logger; } - getConfigForURI(uri: Uri) { - const config = this._graphQLCache.getProjectForFile(uri); - if (config) { - return config; + getAllProjectsForFile(uri: Uri) { + const filePath = normalizeUri(uri); + const projects = Object.values(this._graphQLConfig.projects).filter( + project => project.match(filePath), + ); + + return projects.length > 0 + ? projects + : // Fallback, this always finds at least 1 project + [this._graphQLConfig.getProjectForFile(filePath)]; + } + + getProjectForDocument( + document: string, + uri: Uri, + projects?: GraphQLProjectConfig[], + ) { + if (!document.startsWith('#graphql')) { + // Query is not annotated with #graphql. + // Skip suffix check and return the first project that matches the file. + return ( + projects?.[0] ?? + this._graphQLConfig.getProjectForFile(normalizeUri(uri)) + ); } + + return (projects || this.getAllProjectsForFile(uri)).find(project => { + const ext = project.hasExtension(EXTENSION_NAME) + ? project.extension(EXTENSION_NAME) + : null; + + const suffix = ext?.gqlTagOptions?.annotationSuffix; + + return document.startsWith(`#graphql${suffix ? ':' + suffix : ''}\n`); + }); } public async getDiagnostics( @@ -111,7 +149,7 @@ export class GraphQLLanguageService { // Perform syntax diagnostics first, as this doesn't require // schema/fragment definitions, even the project configuration. let documentHasExtensions = false; - const projectConfig = this.getConfigForURI(uri); + const projectConfig = this.getProjectForDocument(document, uri); // skip validation when there's nothing to validate, prevents noisy unexpected EOF errors if (!projectConfig || !document || document.trim().length < 2) { return []; @@ -218,7 +256,7 @@ export class GraphQLLanguageService { position: IPosition, filePath: Uri, ): Promise> { - const projectConfig = this.getConfigForURI(filePath); + const projectConfig = this.getProjectForDocument(query, filePath); if (!projectConfig) { return []; } @@ -255,7 +293,7 @@ export class GraphQLLanguageService { filePath: Uri, options?: HoverConfig, ): Promise { - const projectConfig = this.getConfigForURI(filePath); + const projectConfig = this.getProjectForDocument(query, filePath); if (!projectConfig) { return ''; } @@ -272,7 +310,7 @@ export class GraphQLLanguageService { position: IPosition, filePath: Uri, ): Promise { - const projectConfig = this.getConfigForURI(filePath); + const projectConfig = this.getProjectForDocument(query, filePath); if (!projectConfig) { return null; } diff --git a/packages/graphql-language-service-server/src/MessageProcessor.ts b/packages/graphql-language-service-server/src/MessageProcessor.ts index e871ba4e340..415d92b87c3 100644 --- a/packages/graphql-language-service-server/src/MessageProcessor.ts +++ b/packages/graphql-language-service-server/src/MessageProcessor.ts @@ -314,6 +314,39 @@ export class MessageProcessor { return false; } + async _getDiagnosticsForAllFileProjects( + contents: CachedContent[], + uri: Uri, + projects: GraphQLProjectConfig[], + ): Promise { + return ( + await Promise.all( + contents.map(async ({ query, range }) => { + const project = this._languageService.getProjectForDocument( + query, + uri, + projects, + ); + + if ( + project?.extensions?.languageService?.enableValidation !== false + ) { + const results = await this._languageService.getDiagnostics( + query, + uri, + this._isRelayCompatMode(query), + ); + if (results && results.length > 0) { + return processDiagnosticsMessage(results, query, range); + } + } + + return []; + }), + ) + ).reduce((left, right) => left.concat(right), []); + } + async handleDidOpenOrSaveNotification( params: DidSaveTextDocumentParams | DidOpenTextDocumentParams, ): Promise { @@ -374,35 +407,28 @@ export class MessageProcessor { return { uri, diagnostics }; } try { - const project = this._graphQLCache.getProjectForFile(uri); - if ( - this._isInitialized && - project?.extensions?.languageService?.enableValidation !== false - ) { - await Promise.all( - contents.map(async ({ query, range }) => { - const results = await this._languageService.getDiagnostics( - query, - uri, - this._isRelayCompatMode(query), - ); - if (results && results.length > 0) { - diagnostics.push( - ...processDiagnosticsMessage(results, query, range), - ); - } - }), + const projects = this._languageService.getAllProjectsForFile(uri); + + if (this._isInitialized) { + diagnostics.push( + ...(await this._getDiagnosticsForAllFileProjects( + contents, + uri, + projects, + )), ); } - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/didOpenOrSave', - projectName: project?.name, - fileName: uri, - }), - ); + for (const project of projects) { + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/didOpenOrSave', + projectName: project?.name, + fileName: uri, + }), + ); + } } catch (err) { this._handleConfigError({ err, uri }); } @@ -431,7 +457,6 @@ export class MessageProcessor { } const { textDocument, contentChanges } = params; const { uri } = textDocument; - const project = this._graphQLCache.getProjectForFile(uri); try { const contentChange = contentChanges.at(-1)!; @@ -453,35 +478,25 @@ export class MessageProcessor { await this._updateFragmentDefinition(uri, contents); await this._updateObjectTypeDefinition(uri, contents); - const diagnostics: Diagnostic[] = []; + const projects = this._languageService.getAllProjectsForFile(uri); - 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), - ); - } + const diagnostics = await this._getDiagnosticsForAllFileProjects( + contents, + uri, + projects, + ); + + for (const project of projects) { + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/didChange', + projectName: project?.name, + fileName: uri, }), ); } - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/didChange', - projectName: project?.name, - fileName: uri, - }), - ); - return { uri, diagnostics }; } catch (err) { this._handleConfigError({ err, uri }); @@ -517,16 +532,18 @@ export class MessageProcessor { if (this._textDocumentCache.has(uri)) { this._textDocumentCache.delete(uri); } - const project = this._graphQLCache.getProjectForFile(uri); + const projects = this._languageService.getAllProjectsForFile(uri); - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/didClose', - projectName: project?.name, - fileName: uri, - }), - ); + for (const project of projects) { + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/didClose', + projectName: project?.name, + fileName: uri, + }), + ); + } } handleShutdownRequest(): void { @@ -589,7 +606,10 @@ export class MessageProcessor { textDocument.uri, ); - const project = this._graphQLCache.getProjectForFile(textDocument.uri); + const project = this._languageService.getProjectForDocument( + textDocument.uri, + query, + ); this._logger.log( JSON.stringify({ @@ -680,41 +700,30 @@ export class MessageProcessor { await this._updateObjectTypeDefinition(uri, contents); try { - const project = this._graphQLCache.getProjectForFile(uri); - if (project) { - await this._updateSchemaIfChanged(project, uri); - } + const projects = this._languageService.getAllProjectsForFile(uri); + await Promise.all( + projects.map(project => + this._updateSchemaIfChanged(project, uri), + ), + ); - let diagnostics: Diagnostic[] = []; - - if ( - project?.extensions?.languageService?.enableValidation !== false - ) { - diagnostics = ( - await Promise.all( - contents.map(async ({ query, range }) => { - const results = await this._languageService.getDiagnostics( - query, - uri, - this._isRelayCompatMode(query), - ); - if (results && results.length > 0) { - return processDiagnosticsMessage(results, query, range); - } - return []; - }), - ) - ).reduce((left, right) => left.concat(right), diagnostics); + const diagnostics = await this._getDiagnosticsForAllFileProjects( + contents, + uri, + projects, + ); + + for (const project of projects) { + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'workspace/didChangeWatchedFiles', + projectName: project?.name, + fileName: uri, + }), + ); } - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'workspace/didChangeWatchedFiles', - projectName: project?.name, - fileName: uri, - }), - ); return { uri, diagnostics }; } catch (err) { this._handleConfigError({ err, uri }); @@ -749,9 +758,13 @@ export class MessageProcessor { throw new Error('`textDocument` and `position` arguments are required.'); } const { textDocument, position } = params; - const project = this._graphQLCache.getProjectForFile(textDocument.uri); - if (project) { - await this._cacheSchemaFilesForProject(project); + const projects = this._languageService.getAllProjectsForFile( + textDocument.uri, + ); + if (projects.length > 0) { + await Promise.all( + projects.map(project => this._cacheSchemaFilesForProject(project)), + ); } const cachedDocument = this._getCachedDocument(textDocument.uri); if (!cachedDocument) { @@ -824,14 +837,16 @@ export class MessageProcessor { }) : []; - this._logger.log( - JSON.stringify({ - type: 'usage', - messageType: 'textDocument/definition', - projectName: project?.name, - fileName: textDocument.uri, - }), - ); + for (const project of projects) { + this._logger.log( + JSON.stringify({ + type: 'usage', + messageType: 'textDocument/definition', + projectName: project?.name, + fileName: textDocument.uri, + }), + ); + } return formatted; } diff --git a/packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService-test.ts b/packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService-test.ts index 0283c277174..962b7c5968a 100644 --- a/packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/GraphQLLanguageService-test.ts @@ -10,7 +10,10 @@ import { join } from 'node:path'; import { GraphQLConfig } from 'graphql-config'; -import { GraphQLLanguageService } from '../GraphQLLanguageService'; +import { + EXTENSION_NAME, + GraphQLLanguageService, +} from '../GraphQLLanguageService'; import { SymbolKind } from 'vscode-languageserver-protocol'; import { Position } from 'graphql-language-service'; import { NoopLogger } from '../Logger'; @@ -18,8 +21,23 @@ import { NoopLogger } from '../Logger'; const MOCK_CONFIG = { filepath: join(__dirname, '.graphqlrc.yml'), config: { - schema: './__schema__/StarWarsSchema.graphql', - documents: ['./queries/**', '**/*.graphql'], + projects: { + default: { + schema: './__schema__/StarWarsSchema.graphql', + documents: ['./queries/**', '**/*.graphql'], + }, + another: { + schema: 'schema { query: Query } type Query { test: String }', + documents: ['./queries/**/*.ts', './somewhere/**/*.ts'], + extensions: { + [EXTENSION_NAME]: { + gqlTagOptions: { + annotationSuffix: 'test', + }, + }, + }, + }, + }, }, }; @@ -31,7 +49,7 @@ describe('GraphQLLanguageService', () => { }, getGraphQLConfig() { - return new GraphQLConfig(MOCK_CONFIG, []); + return new GraphQLConfig(MOCK_CONFIG, [() => ({ name: EXTENSION_NAME })]); }, getProjectForFile(uri: string) { @@ -221,4 +239,35 @@ describe('GraphQLLanguageService', () => { expect(result[1].location.range.end.line).toEqual(4); expect(result[1].location.range.end.character).toEqual(5); }); + + it('finds the correct project for the given query', () => { + const getProjectName = (query: string, path: string) => + languageService.getProjectForDocument(query, path)?.name; + + const QUERY_NO_SUFFIX = '#graphql\n query { test }'; + const QUERY_TEST_SUFFIX = '#graphql:test\n query { test }'; + + const pathThatMatchesBothProjects = './queries/test.ts'; + const pathThatMatchesOnlyProjectAnother = './somewhere/test.ts'; + + // Matches path for both projects: + // #graphql => default + expect( + getProjectName(QUERY_NO_SUFFIX, pathThatMatchesBothProjects), + ).toEqual('default'); + // #graphql:test => another + expect( + getProjectName(QUERY_TEST_SUFFIX, pathThatMatchesBothProjects), + ).toEqual('another'); + + // Only matches path for project 'another': + // #graphql => undefined + expect( + getProjectName(QUERY_NO_SUFFIX, pathThatMatchesOnlyProjectAnother), + ).toEqual(undefined); + // #graphql:test => another + expect( + getProjectName(QUERY_TEST_SUFFIX, pathThatMatchesOnlyProjectAnother), + ).toEqual('another'); + }); }); 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 e2c2ecdaaf9..651444f8534 100644 --- a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts @@ -68,6 +68,13 @@ describe('MessageProcessor', () => { getDiagnostics(_query, _uri) { return []; }, + getAllProjectsForFile(uri: string) { + const project = messageProcessor._graphQLCache.getProjectForFile(uri); + return project ? [project] : []; + }, + getProjectForDocument(_query: string, uri: string) { + return this.getAllProjectsForFile(uri)[0]; + }, async getDocumentSymbols(_query: string, uri: string) { return [ { diff --git a/packages/graphql-language-service-server/src/findGraphQLTags.ts b/packages/graphql-language-service-server/src/findGraphQLTags.ts index 4db6d001aed..6761c42681f 100644 --- a/packages/graphql-language-service-server/src/findGraphQLTags.ts +++ b/packages/graphql-language-service-server/src/findGraphQLTags.ts @@ -128,8 +128,7 @@ export function findGraphQLTags( }, TemplateLiteral(node: TemplateLiteral) { // check if the template literal is prefixed with #graphql - const hasGraphQLPrefix = - node.quasis[0].value.raw.startsWith('#graphql\n'); + const hasGraphQLPrefix = node.quasis[0].value.raw.startsWith('#graphql'); // check if the template expression has /* GraphQL */ comment const hasGraphQLComment = Boolean( node.leadingComments?.[0]?.value.match(/^\s*GraphQL\s*$/), diff --git a/packages/vscode-graphql/README.md b/packages/vscode-graphql/README.md index 6cad22385da..09b9280d6d3 100644 --- a/packages/vscode-graphql/README.md +++ b/packages/vscode-graphql/README.md @@ -128,6 +128,11 @@ module.exports = { }, }, ], + languageService: { + gqlTagOptions: { + annotationSuffix: 'db', + }, + }, }, }, }, @@ -137,6 +142,24 @@ module.exports = { Notice that `documents` key supports glob pattern and hence `["**/*.graphql"]` is also valid. +Normally, you would point your `documents` in each project to different files to ensure that only one schema is used for the queries. However, you can also mix queries for different schemas into the same file by adding a `#graphql:` comment to each query, matching the `languageService.gqlTagOptions.annotationSuffix` for the project: + +```ts +// file.js + +const queryForDefaultProject = `#graphql + query { something } +`; + +const queryForDbProject = `#graphql:db + query { something } +`; + +const queryForCmsProject = `#graphql:cms + query { something } +`; +``` + ## Frequently Asked Questions @@ -300,14 +323,14 @@ further! This plugin uses the [GraphQL language server](https://github.com/graphql/graphql-language-service-server) -1. Clone the repository - https://github.com/graphql/graphiql -1. `yarn` -1. Run "VScode Extension" launcher in vscode -1. This will open another VSCode instance with extension enabled -1. Open a project with a graphql config file - ":electric_plug: graphql" in - VSCode status bar indicates that the extension is in use -1. Logs for GraphQL language service will appear in output section under - GraphQL Language Service +1. Clone the repository - +1. `yarn` +1. Run "VScode Extension" launcher in vscode +1. This will open another VSCode instance with extension enabled +1. Open a project with a graphql config file - ":electric_plug: graphql" in + VSCode status bar indicates that the extension is in use +1. Logs for GraphQL language service will appear in output section under + GraphQL Language Service ### Contributing back to this project