diff --git a/README.md b/README.md index fb42de09..6c85e18a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Partial support for [Microsoft's Language Server Protocol](https://github.com/Mi Currently supported features include: - Diagnostics (GraphQL syntax linting/validations) (**spec-compliant**) - Autocomplete suggestions (**spec-compliant**) -- Hyperlink to fragment definitions (**spec-compliant**) +- Hyperlink to fragment definitions and named types (type, input, enum) definitions (**spec-compliant**) - Outline view support for queries diff --git a/packages/interface/src/GraphQLLanguageService.js b/packages/interface/src/GraphQLLanguageService.js index ac2430e8..b262db30 100644 --- a/packages/interface/src/GraphQLLanguageService.js +++ b/packages/interface/src/GraphQLLanguageService.js @@ -13,6 +13,8 @@ import type { FragmentSpreadNode, FragmentDefinitionNode, OperationDefinitionNode, + TypeDefinitionNode, + NamedTypeNode, } from 'graphql'; import type { CompletionItem, @@ -43,6 +45,7 @@ import { DIRECTIVE_DEFINITION, FRAGMENT_SPREAD, OPERATION_DEFINITION, + NAMED_TYPE, } from 'graphql/language/kinds'; import {parse, print} from 'graphql'; @@ -52,6 +55,7 @@ import {validateQuery, getRange, SEVERITY} from './getDiagnostics'; import { getDefinitionQueryResultForFragmentSpread, getDefinitionQueryResultForDefinitionNode, + getDefinitionQueryResultForNamedType, } from './getDefinition'; import {getASTNodeAtPosition} from 'graphql-language-service-utils'; @@ -224,11 +228,63 @@ export class GraphQLLanguageService { query, (node: FragmentDefinitionNode | OperationDefinitionNode), ); + case NAMED_TYPE: + return this._getDefinitionForNamedType( + query, + ast, + node, + filePath, + projectConfig, + ); } } return null; } + async _getDefinitionForNamedType( + query: string, + ast: DocumentNode, + node: NamedTypeNode, + filePath: Uri, + projectConfig: GraphQLProjectConfig, + ): Promise { + const objectTypeDefinitions = await this._graphQLCache.getObjectTypeDefinitions( + projectConfig, + ); + + const dependencies = await this._graphQLCache.getObjectTypeDependenciesForAST( + ast, + objectTypeDefinitions, + ); + + const localObjectTypeDefinitions = ast.definitions.filter( + definition => + definition.kind === OBJECT_TYPE_DEFINITION || + definition.kind === INPUT_OBJECT_TYPE_DEFINITION || + definition.kind === ENUM_TYPE_DEFINITION, + ); + + const typeCastedDefs = ((localObjectTypeDefinitions: any): Array< + TypeDefinitionNode, + >); + + const localOperationDefinationInfos = typeCastedDefs.map( + (definition: TypeDefinitionNode) => ({ + filePath, + content: query, + definition, + }), + ); + + const result = await getDefinitionQueryResultForNamedType( + query, + node, + dependencies.concat(localOperationDefinationInfos), + ); + + return result; + } + async _getDefinitionForFragmentSpread( query: string, ast: DocumentNode, diff --git a/packages/interface/src/__tests__/GraphQLLanguageService-test.js b/packages/interface/src/__tests__/GraphQLLanguageService-test.js index b5f65738..2f65a38e 100644 --- a/packages/interface/src/__tests__/GraphQLLanguageService-test.js +++ b/packages/interface/src/__tests__/GraphQLLanguageService-test.js @@ -16,7 +16,8 @@ import {GraphQLConfig} from 'graphql-config'; import {GraphQLLanguageService} from '../GraphQLLanguageService'; const MOCK_CONFIG = { - includes: ['./queries/**'], + schemaPath: './__schema__/StarWarsSchema.graphql', + includes: ['./queries/**', '**/*.graphql'], }; describe('GraphQLLanguageService', () => { @@ -24,6 +25,42 @@ describe('GraphQLLanguageService', () => { getGraphQLConfig() { return new GraphQLConfig(MOCK_CONFIG, join(__dirname, '.graphqlconfig')); }, + + getObjectTypeDefinitions() { + return { + Episode: { + filePath: 'fake file path', + content: 'fake file content', + definition: { + name: { + value: 'Episode', + }, + loc: { + start: 293, + end: 335, + }, + }, + }, + }; + }, + + getObjectTypeDependenciesForAST() { + return [ + { + filePath: 'fake file path', + content: 'fake file content', + definition: { + name: { + value: 'Episode', + }, + loc: { + start: 293, + end: 335, + }, + }, + }, + ]; + }, }; let languageService; @@ -38,4 +75,13 @@ describe('GraphQLLanguageService', () => { ); expect(diagnostics.length).to.equal(1); }); + + it('runs definition service as expected', async () => { + const definitionQueryResult = await languageService.getDefinition( + 'type Query { hero(episode: Episode): Character }', + {line: 0, character: 28}, + './queries/definitionQuery.graphql', + ); + expect(definitionQueryResult.definitions.length).to.equal(1); + }); }); diff --git a/packages/interface/src/__tests__/getDefinition-test.js b/packages/interface/src/__tests__/getDefinition-test.js index e359d65f..5a5c4069 100644 --- a/packages/interface/src/__tests__/getDefinition-test.js +++ b/packages/interface/src/__tests__/getDefinition-test.js @@ -11,9 +11,46 @@ import {expect} from 'chai'; import {describe, it} from 'mocha'; import {parse} from 'graphql'; -import {getDefinitionQueryResultForFragmentSpread} from '../getDefinition'; +import { + getDefinitionQueryResultForFragmentSpread, + getDefinitionQueryResultForNamedType, +} from '../getDefinition'; describe('getDefinition', () => { + describe('getDefinitionQueryResultForNamedType', () => { + it('returns correct Position', async () => { + const query = `type Query { + hero(episode: Episode): Character + } + + type Episode { + id: ID! + } + `; + const parsedQuery = parse(query); + const namedTypeDefinition = parsedQuery.definitions[0].fields[0].type; + + const result = await getDefinitionQueryResultForNamedType( + query, + { + ...namedTypeDefinition, + }, + [ + { + file: 'someFile', + content: query, + definition: { + ...namedTypeDefinition, + }, + }, + ], + ); + expect(result.definitions.length).to.equal(1); + expect(result.definitions[0].position.line).to.equal(1); + expect(result.definitions[0].position.character).to.equal(32); + }); + }); + describe('getDefinitionQueryResultForFragmentSpread', () => { it('returns correct Position', async () => { const query = `query A { @@ -39,7 +76,7 @@ describe('getDefinition', () => { ); expect(result.definitions.length).to.equal(1); expect(result.definitions[0].position.line).to.equal(1); - expect(result.definitions[0].position.character).to.equal(15); + expect(result.definitions[0].position.character).to.equal(6); }); }); }); diff --git a/packages/interface/src/__tests__/queries/definitionQuery.graphql b/packages/interface/src/__tests__/queries/definitionQuery.graphql new file mode 100644 index 00000000..a1028da7 --- /dev/null +++ b/packages/interface/src/__tests__/queries/definitionQuery.graphql @@ -0,0 +1 @@ +type Query { hero(episode: Episode): Character } \ No newline at end of file diff --git a/packages/interface/src/getDefinition.js b/packages/interface/src/getDefinition.js index 2dc87505..aff0af3e 100644 --- a/packages/interface/src/getDefinition.js +++ b/packages/interface/src/getDefinition.js @@ -13,6 +13,8 @@ import type { FragmentSpreadNode, FragmentDefinitionNode, OperationDefinitionNode, + NamedTypeNode, + TypeDefinitionNode, } from 'graphql'; import type { Definition, @@ -21,6 +23,7 @@ import type { Position, Range, Uri, + ObjectTypeInfo, } from 'graphql-language-service-types'; import {locToRange, offsetToPosition} from 'graphql-language-service-utils'; import invariant from 'assert'; @@ -39,6 +42,29 @@ function getPosition(text: string, node: ASTNode): Position { return offsetToPosition(text, location.start); } +export async function getDefinitionQueryResultForNamedType( + text: string, + node: NamedTypeNode, + dependencies: Array, +): Promise { + const name = node.name.value; + const defNodes = dependencies.filter( + ({definition}) => definition.name && definition.name.value === name, + ); + if (defNodes.length === 0) { + process.stderr.write(`Definition not found for GraphQL type ${name}`); + return {queryRange: [], definitions: []}; + } + const definitions: Array = defNodes.map( + ({filePath, content, definition}) => + getDefinitionForNodeDefinition(filePath || '', content, definition), + ); + return { + definitions, + queryRange: definitions.map(_ => getRange(text, node)), + }; +} + export async function getDefinitionQueryResultForFragmentSpread( text: string, fragment: FragmentSpreadNode, @@ -82,7 +108,25 @@ function getDefinitionForFragmentDefinition( invariant(name, 'Expected ASTNode to have a Name.'); return { path, - position: getPosition(text, name), + position: getPosition(text, definition), + range: getRange(text, definition), + name: name.value || '', + language: LANGUAGE, + // This is a file inside the project root, good enough for now + projectRoot: path, + }; +} + +function getDefinitionForNodeDefinition( + path: Uri, + text: string, + definition: TypeDefinitionNode, +): Definition { + const name = definition.name; + invariant(name, 'Expected ASTNode to have a Name.'); + return { + path, + position: getPosition(text, definition), range: getRange(text, definition), name: name.value || '', language: LANGUAGE, diff --git a/packages/server/src/GraphQLCache.js b/packages/server/src/GraphQLCache.js index 05660a5a..111ca97f 100644 --- a/packages/server/src/GraphQLCache.js +++ b/packages/server/src/GraphQLCache.js @@ -16,6 +16,7 @@ import type { GraphQLFileMetadata, GraphQLFileInfo, FragmentInfo, + ObjectTypeInfo, Uri, GraphQLProjectConfig, } from 'graphql-language-service-types'; @@ -65,6 +66,7 @@ export class GraphQLCache implements GraphQLCacheInterface { _schemaMap: Map; _typeExtensionMap: Map; _fragmentDefinitionsCache: Map>; + _typeDefinitionsCache: Map>; constructor(configDir: Uri, graphQLConfig: GraphQLConfig): void { this._configDir = configDir; @@ -72,6 +74,7 @@ export class GraphQLCache implements GraphQLCacheInterface { this._graphQLFileListCache = new Map(); this._schemaMap = new Map(); this._fragmentDefinitionsCache = new Map(); + this._typeDefinitionsCache = new Map(); this._typeExtensionMap = new Map(); } @@ -177,6 +180,112 @@ export class GraphQLCache implements GraphQLCacheInterface { return fragmentDefinitions; }; + getObjectTypeDependencies = async ( + query: string, + objectTypeDefinitions: ?Map, + ): Promise> => { + // If there isn't context for object type references, + // return an empty array. + if (!objectTypeDefinitions) { + return []; + } + // If the query cannot be parsed, validations cannot happen yet. + // Return an empty array. + let parsedQuery; + try { + parsedQuery = parse(query); + } catch (error) { + return []; + } + return this.getObjectTypeDependenciesForAST( + parsedQuery, + objectTypeDefinitions, + ); + }; + + getObjectTypeDependenciesForAST = async ( + parsedQuery: ASTNode, + objectTypeDefinitions: Map, + ): Promise> => { + if (!objectTypeDefinitions) { + return []; + } + + const existingObjectTypes = new Map(); + const referencedObjectTypes = new Set(); + + visit(parsedQuery, { + ObjectTypeDefinition(node) { + existingObjectTypes.set(node.name.value, true); + }, + InputObjectTypeDefinition(node) { + existingObjectTypes.set(node.name.value, true); + }, + EnumTypeDefinition(node) { + existingObjectTypes.set(node.name.value, true); + }, + NamedType(node) { + if (!referencedObjectTypes.has(node.name.value)) { + referencedObjectTypes.add(node.name.value); + } + }, + }); + + const asts = new Set(); + referencedObjectTypes.forEach(name => { + if (!existingObjectTypes.has(name) && objectTypeDefinitions.has(name)) { + asts.add(nullthrows(objectTypeDefinitions.get(name))); + } + }); + + const referencedObjects = []; + + asts.forEach(ast => { + visit(ast.definition, { + NamedType(node) { + if ( + !referencedObjectTypes.has(node.name.value) && + objectTypeDefinitions.get(node.name.value) + ) { + asts.add(nullthrows(objectTypeDefinitions.get(node.name.value))); + referencedObjectTypes.add(node.name.value); + } + }, + }); + if (!existingObjectTypes.has(ast.definition.name.value)) { + referencedObjects.push(ast); + } + }); + + return referencedObjects; + }; + + getObjectTypeDefinitions = async ( + projectConfig: GraphQLProjectConfig, + ): Promise> => { + // This function may be called from other classes. + // If then, check the cache first. + const rootDir = projectConfig.configDir; + if (this._typeDefinitionsCache.has(rootDir)) { + return this._typeDefinitionsCache.get(rootDir) || new Map(); + } + const filesFromInputDirs = await this._readFilesFromInputDirs( + rootDir, + projectConfig.includes, + ); + const list = filesFromInputDirs.filter(fileInfo => + projectConfig.includesFile(fileInfo.filePath), + ); + const { + objectTypeDefinitions, + graphQLFileMap, + } = await this.readAllGraphQLFiles(list); + this._typeDefinitionsCache.set(rootDir, objectTypeDefinitions); + this._graphQLFileListCache.set(rootDir, graphQLFileMap); + + return objectTypeDefinitions; + }; + handleWatchmanSubscribeEvent = ( rootDir: string, projectConfig: GraphQLProjectConfig, @@ -233,6 +342,7 @@ export class GraphQLCache implements GraphQLCacheInterface { } this.updateFragmentDefinitionCache(rootDir, filePath, exists); + this.updateObjectTypeDefinitionCache(rootDir, filePath, exists); } }); } @@ -383,6 +493,73 @@ export class GraphQLCache implements GraphQLCacheInterface { } } + async updateObjectTypeDefinition( + rootDir: Uri, + filePath: Uri, + contents: Array, + ): Promise { + const cache = this._typeDefinitionsCache.get(rootDir); + const asts = contents.map(({query}) => { + try { + return {ast: parse(query), query}; + } catch (error) { + return {ast: null, query}; + } + }); + if (cache) { + // first go through the types list to delete the ones from this file + cache.forEach((value, key) => { + if (value.filePath === filePath) { + cache.delete(key); + } + }); + asts.forEach(({ast, query}) => { + if (!ast) { + return; + } + ast.definitions.forEach(definition => { + if ( + definition.kind === OBJECT_TYPE_DEFINITION || + definition.kind === INPUT_OBJECT_TYPE_DEFINITION || + definition.kind === ENUM_TYPE_DEFINITION + ) { + cache.set(definition.name.value, { + filePath, + content: query, + definition, + }); + } + }); + }); + } + } + + async updateObjectTypeDefinitionCache( + rootDir: Uri, + filePath: Uri, + exists: boolean, + ): Promise { + const fileAndContent = exists + ? await this.promiseToReadGraphQLFile(filePath) + : null; + // In the case of type definitions, the cache could just map the + // definition name to the parsed ast, whether or not it existed + // previously. + // For delete, remove the entry from the set. + if (!exists) { + const cache = this._typeDefinitionsCache.get(rootDir); + if (cache) { + cache.delete(filePath); + } + } else if (fileAndContent && fileAndContent.queries) { + this.updateObjectTypeDefinition( + rootDir, + filePath, + fileAndContent.queries, + ); + } + } + _extendSchema( schema: GraphQLSchema, schemaPath: ?string, @@ -550,6 +727,7 @@ export class GraphQLCache implements GraphQLCacheInterface { readAllGraphQLFiles = async ( list: Array, ): Promise<{ + objectTypeDefinitions: Map, fragmentDefinitions: Map, graphQLFileMap: Map, }> => { @@ -593,9 +771,11 @@ export class GraphQLCache implements GraphQLCacheInterface { processGraphQLFiles = ( responses: Array, ): { + objectTypeDefinitions: Map, fragmentDefinitions: Map, graphQLFileMap: Map, } => { + const objectTypeDefinitions = new Map(); const fragmentDefinitions = new Map(); const graphQLFileMap = new Map(); @@ -612,6 +792,17 @@ export class GraphQLCache implements GraphQLCacheInterface { definition, }); } + if ( + definition.kind === OBJECT_TYPE_DEFINITION || + definition.kind === INPUT_OBJECT_TYPE_DEFINITION || + definition.kind === ENUM_TYPE_DEFINITION + ) { + objectTypeDefinitions.set(definition.name.value, { + filePath, + content, + definition, + }); + } }); }); } @@ -626,7 +817,7 @@ export class GraphQLCache implements GraphQLCacheInterface { }); }); - return {fragmentDefinitions, graphQLFileMap}; + return {objectTypeDefinitions, fragmentDefinitions, graphQLFileMap}; }; /** diff --git a/packages/server/src/MessageProcessor.js b/packages/server/src/MessageProcessor.js index e155ee5a..7ad6aa7b 100644 --- a/packages/server/src/MessageProcessor.js +++ b/packages/server/src/MessageProcessor.js @@ -279,6 +279,7 @@ export class MessageProcessor { } this._updateFragmentDefinition(uri, contents); + this._updateObjectTypeDefinition(uri, contents); // Send the diagnostics onChange as well const diagnostics = []; @@ -482,6 +483,7 @@ export class MessageProcessor { const contents = getQueryAndRange(text, uri); this._updateFragmentDefinition(uri, contents); + this._updateObjectTypeDefinition(uri, contents); const diagnostics = (await Promise.all( contents.map(async ({query, range}) => { @@ -515,6 +517,11 @@ export class MessageProcessor { change.uri, false, ); + this._graphQLCache.updateObjectTypeDefinitionCache( + this._graphQLCache.getGraphQLConfig().configDir, + change.uri, + false, + ); } }), ); @@ -612,6 +619,19 @@ export class MessageProcessor { ); } + async _updateObjectTypeDefinition( + uri: Uri, + contents: Array, + ): Promise { + const rootDir = this._graphQLCache.getGraphQLConfig().configDir; + + await this._graphQLCache.updateObjectTypeDefinition( + rootDir, + new URL(uri).pathname, + contents, + ); + } + _getCachedDocument(uri: string): ?CachedDocumentType { if (this._textDocumentCache.has(uri)) { const cachedDocument = this._textDocumentCache.get(uri); diff --git a/packages/server/src/__tests__/GraphQLCache-test.js b/packages/server/src/__tests__/GraphQLCache-test.js index 51e6ad40..ca269fe9 100644 --- a/packages/server/src/__tests__/GraphQLCache-test.js +++ b/packages/server/src/__tests__/GraphQLCache-test.js @@ -183,4 +183,41 @@ describe('GraphQLCache', () => { expect(fragmentDefinitions.get('testFragment')).to.be.undefined; }); }); + + describe('getNamedTypeDependencies', () => { + const query = `type Query { + hero(episode: Episode): Character + } + + type Episode { + id: ID! + } + `; + const parsedQuery = parse(query); + + const namedTypeDefinitions = new Map(); + namedTypeDefinitions.set('Character', { + file: 'someOtherFilePath', + content: query, + definition: { + kind: 'ObjectTypeDefinition', + name: { + kind: 'Name', + value: 'Character', + }, + loc: { + start: 0, + end: 0, + }, + }, + }); + + it('finds named types referenced from the SDL', async () => { + const result = await cache.getObjectTypeDependenciesForAST( + parsedQuery, + namedTypeDefinitions, + ); + expect(result.length).to.equal(1); + }); + }); }); diff --git a/packages/server/src/__tests__/MessageProcessor-test.js b/packages/server/src/__tests__/MessageProcessor-test.js index 9bbe61f8..07f4bd00 100644 --- a/packages/server/src/__tests__/MessageProcessor-test.js +++ b/packages/server/src/__tests__/MessageProcessor-test.js @@ -48,6 +48,7 @@ describe('MessageProcessor', () => { }; }, updateFragmentDefinition() {}, + updateObjectTypeDefinition() {}, handleWatchmanSubscribeEvent() {}, }; messageProcessor._languageService = { diff --git a/packages/types/src/index.js b/packages/types/src/index.js index 16229725..d136cf9d 100644 --- a/packages/types/src/index.js +++ b/packages/types/src/index.js @@ -13,6 +13,8 @@ import type { ASTNode, DocumentNode, FragmentDefinitionNode, + NamedTypeNode, + TypeDefinitionNode, } from 'graphql/language'; import type {ValidationContext} from 'graphql/validation'; import type { @@ -82,6 +84,32 @@ export type GraphQLConfigurationExtension = { export interface GraphQLCache { getGraphQLConfig: () => GraphQLConfig; + getObjectTypeDependencies: ( + query: string, + fragmentDefinitions: ?Map, + ) => Promise>; + + getObjectTypeDependenciesForAST: ( + parsedQuery: ASTNode, + fragmentDefinitions: Map, + ) => Promise>; + + getObjectTypeDefinitions: ( + graphQLConfig: GraphQLProjectConfig, + ) => Promise>; + + +updateObjectTypeDefinition: ( + rootDir: Uri, + filePath: Uri, + contents: Array, + ) => Promise; + + +updateObjectTypeDefinitionCache: ( + rootDir: Uri, + filePath: Uri, + exists: boolean, + ) => Promise; + getFragmentDependencies: ( query: string, fragmentDefinitions: ?Map, @@ -212,6 +240,18 @@ export type FragmentInfo = { definition: FragmentDefinitionNode, }; +export type NamedTypeInfo = { + filePath?: Uri, + content: string, + definition: NamedTypeNode, +}; + +export type ObjectTypeInfo = { + filePath?: Uri, + content: string, + definition: TypeDefinitionNode, +}; + export type CustomValidationRule = (context: ValidationContext) => Object; export type Diagnostic = {