UNPKG

graphql-language-service

Version:

The official, runtime independent Language Service for GraphQL

1,022 lines (952 loc) 30.6 kB
/** * 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 { AutocompleteSuggestionOptions, CompletionItem, } from 'graphql-language-service'; import fs from 'node:fs'; import { buildSchema, FragmentDefinitionNode, GraphQLSchema, parse, version as graphQLVersion, GraphQLString, GraphQLInt, GraphQLBoolean, GraphQLDeprecatedDirective, GraphQLSkipDirective, GraphQLIncludeDirective, } from 'graphql'; import { Position } from '../../utils'; import path from 'node:path'; import { getAutocompleteSuggestions } from '../getAutocompleteSuggestions'; // import { InsertTextFormat } from 'vscode-languageserver-types'; const expectedResults = { droid: { label: 'droid', detail: 'Droid', }, hero: { label: 'hero', detail: 'Character', }, human: { label: 'human', detail: 'Human', }, inputTypeTest: { label: 'inputTypeTest', detail: 'TestType', }, appearsIn: { label: 'appearsIn', detail: '[Episode]', }, friends: { label: 'friends', detail: '[Character]', }, union: { label: 'union', detail: 'TestUnion', }, __typename: { label: '__typename', detail: 'String!', documentation: 'The name of the current Object type at runtime.', }, }; const suggestionCommand = { command: 'editor.action.triggerSuggest', title: 'Suggestions', }; describe('getAutocompleteSuggestions', () => { let schema: GraphQLSchema; beforeEach(async () => { // graphQLVersion = pkg.version; const schemaIDL = fs.readFileSync( path.join(__dirname, '__schema__/StarWarsSchema.graphql'), 'utf8', ); schema = buildSchema(schemaIDL); }); // Returns a sorted autocomplete suggestions in an increasing order. function testSuggestions( query: string, point: Position, externalFragments?: FragmentDefinitionNode[], options?: AutocompleteSuggestionOptions & { ignoreInsert?: boolean }, ): Array<CompletionItem> { return getAutocompleteSuggestions( schema, query, point, undefined, externalFragments, options, ) .filter(field => !['__schema', '__type'].includes(field.label)) .sort((a, b) => a.label.localeCompare(b.label)) .map(suggestion => { // TODO: A PR where we do `const { type, ..rest} = suggestion; return rest;` // and validate the entire completion object - kinds, documentation, etc const response = { label: suggestion.label } as CompletionItem; if (suggestion.detail) { response.detail = String(suggestion.detail); } if (suggestion.insertText && !options?.ignoreInsert) { response.insertText = suggestion.insertText; } if (suggestion.insertTextFormat && !options?.ignoreInsert) { response.insertTextFormat = suggestion.insertTextFormat; } if (suggestion.command && !options?.ignoreInsert) { response.command = suggestion.command; } if (suggestion.documentation?.length) { response.documentation = suggestion.documentation; } if (suggestion.labelDetails && !options?.ignoreInsert) { response.labelDetails = suggestion.labelDetails; } return response; }); } describe('with Operation types', () => { const expectedDirectiveSuggestions = [ { label: 'include', documentation: GraphQLIncludeDirective.description }, { label: 'skip', documentation: GraphQLSkipDirective.description }, ]; // TODO: remove this once defer and stream are merged to `graphql` if (graphQLVersion.startsWith('16.0.0-experimental-stream-defer')) { // @ts-expect-error expectedDirectiveSuggestions.push({ label: 'stream' }, { label: 'test' }); } else { // @ts-expect-error expectedDirectiveSuggestions.push({ label: 'test' }); } it('provides correct sortText response', () => { const result = getAutocompleteSuggestions( schema, '{ h', new Position(0, 3), ).map(({ sortText, label, detail }) => ({ sortText, label, detail })); expect(result).toEqual([ { sortText: '0hero', label: 'hero', detail: 'Character', }, { sortText: '1human', label: 'human', detail: 'Human', }, { sortText: '7__schema', label: '__schema', detail: '__Schema!', }, ]); }); it('provides correct initial keywords', () => { expect(testSuggestions('', new Position(0, 0))).toEqual([ { label: '{' }, { label: 'extend' }, { label: 'fragment' }, { label: 'input' }, { label: 'interface' }, { label: 'mutation' }, { label: 'query' }, { label: 'scalar' }, { label: 'schema' }, { label: 'subscription' }, { label: 'type' }, { label: 'union' }, ]); expect(testSuggestions('q', new Position(0, 1))).toEqual([ { label: '{' }, { label: 'query' }, ]); }); it('provides correct top level suggestions when a simple query is already present', () => { // Below should provide initial keywords expect(testSuggestions(' { id }', new Position(0, 0))).toEqual([ { label: '{' }, { label: 'fragment' }, { label: 'mutation' }, { label: 'query' }, { label: 'subscription' }, ]); // Below should provide root field names expect( testSuggestions(' {}', new Position(0, 2), [], { ignoreInsert: true, }), ).toEqual([ expectedResults.__typename, expectedResults.droid, expectedResults.hero, expectedResults.human, expectedResults.inputTypeTest, expectedResults.union, ]); // Test for query text with empty lines expect( testSuggestions( ` query name { ...testFragment } `, new Position(2, 0), [], { ignoreInsert: true, }, ), ).toEqual([ expectedResults.__typename, expectedResults.droid, expectedResults.hero, expectedResults.human, expectedResults.inputTypeTest, expectedResults.union, ]); }); it('provides correct field name suggestions', () => { const result = testSuggestions('{ ', new Position(0, 2), [], { ignoreInsert: true, }); expect(result).toEqual([ expectedResults.__typename, expectedResults.droid, expectedResults.hero, expectedResults.human, expectedResults.inputTypeTest, expectedResults.union, ]); }); it('provides correct field name suggestions after filtered', () => { const result = testSuggestions('{ h ', new Position(0, 3), [], { ignoreInsert: true, }); expect(result).toEqual([expectedResults.hero, expectedResults.human]); }); it('provides correct field name suggestions with alias', () => { const result = testSuggestions( '{ alias: human(id: "1") { ', new Position(0, 26), [], { ignoreInsert: true, }, ); expect(result).toEqual([ expectedResults.__typename, expectedResults.appearsIn, expectedResults.friends, { label: 'id', detail: 'String!' }, { label: 'name', detail: 'String' }, { label: 'secretBackstory', detail: 'String' }, ]); }); it('provides correct field name suggestions with insertText', () => { const result = testSuggestions('{ ', new Position(0, 2), [], { ignoreInsert: false, fillLeafsOnComplete: true, }); expect(result).toEqual([ { ...expectedResults.__typename, command: suggestionCommand, insertTextFormat: 2, insertText: '__typename\n', labelDetails: { detail: ' String!' }, }, { ...expectedResults.droid, command: suggestionCommand, insertTextFormat: 2, insertText: 'droid(id: $1) {\n $1\n}', labelDetails: { detail: ' Droid' }, }, { ...expectedResults.hero, command: suggestionCommand, insertTextFormat: 2, insertText: 'hero {\n $1\n}', labelDetails: { detail: ' Character' }, }, { ...expectedResults.human, command: suggestionCommand, insertTextFormat: 2, insertText: 'human(id: $1) {\n $1\n}', labelDetails: { detail: ' Human' }, }, { ...expectedResults.inputTypeTest, command: suggestionCommand, insertTextFormat: 2, insertText: 'inputTypeTest {\n $1\n}', labelDetails: { detail: ' TestType' }, }, { label: 'union', insertTextFormat: 2, insertText: 'union {\n $1\n}', detail: 'TestUnion', command: suggestionCommand, labelDetails: { detail: ' TestUnion', }, }, ]); }); it('provides correct type suggestions for fragments', () => { const result = testSuggestions('fragment test on ', new Position(0, 17)); expect(result).toEqual([ { label: 'AnotherInterface' }, { label: 'Character' }, { label: 'Droid' }, { label: 'Human' }, { label: 'Query' }, { label: 'TestInterface' }, { label: 'TestType' }, { label: 'TestUnion' }, ]); }); it('provides correct field suggestions for fragments', () => { const result = testSuggestions( 'fragment test on Human { ', new Position(0, 25), [], { ignoreInsert: true, }, ); expect(result).toEqual([ expectedResults.__typename, expectedResults.appearsIn, expectedResults.friends, { label: 'id', detail: 'String!' }, { label: 'name', detail: 'String' }, { label: 'secretBackstory', detail: 'String' }, ]); }); it('provides correct argument suggestions', () => { const result = testSuggestions('{ human (', new Position(0, 9)); expect(result).toEqual([ { label: 'id', insertText: 'id: ', command: suggestionCommand, insertTextFormat: 2, labelDetails: { detail: ' String!' }, }, ]); }); it('provides correct argument suggestions when using aliases', () => { const result = testSuggestions( '{ aliasTest: human( ', new Position(0, 20), ); expect(result).toEqual([ { label: 'id', command: suggestionCommand, insertText: 'id: ', insertTextFormat: 2, labelDetails: { detail: ' String!' }, }, ]); }); const metaArgs = [ { label: '__DirectiveLocation', documentation: 'A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.', }, { label: '__TypeKind', documentation: 'An enum describing what kind of type a given `__Type` is.', }, ]; it('provides correct input type suggestions', () => { const result = testSuggestions( 'query($exampleVariable: ) { ', new Position(0, 24), ); expect(result).toEqual([ ...metaArgs, { label: 'Boolean', documentation: GraphQLBoolean.description }, { label: 'Episode' }, { label: 'InputType' }, { label: 'Int', documentation: GraphQLInt.description }, { label: 'String', documentation: GraphQLString.description }, ]); }); it('provides filtered input type suggestions', () => { const result = testSuggestions( 'query($exampleVariable: In) { ', new Position(0, 26), ); expect(result).toEqual([ ...metaArgs, { label: 'InputType' }, { label: 'Int', documentation: GraphQLInt.description }, { label: 'String', documentation: GraphQLString.description }, ]); }); it('provides correct typeCondition suggestions', () => { const suggestionsOnQuery = testSuggestions( '{ ... on ', new Position(0, 9), ); expect( suggestionsOnQuery.filter(({ label }) => !label.startsWith('__')), ).toEqual([{ label: 'Query' }]); const suggestionsOnCompositeType = testSuggestions( '{ hero(episode: JEDI) { ... on } }', new Position(0, 31), ); expect(suggestionsOnCompositeType).toEqual([ { label: 'Character' }, { label: 'Droid' }, { label: 'Human' }, ]); expect( testSuggestions( 'fragment Foo on Character { ... on }', new Position(0, 35), ), ).toEqual([ { label: 'Character' }, { label: 'Droid' }, { label: 'Human' }, ]); }); it('provides correct typeCondition suggestions on fragment', () => { const result = testSuggestions('fragment Foo on {}', new Position(0, 16)); expect(result.filter(({ label }) => !label.startsWith('__'))).toEqual([ { label: 'AnotherInterface' }, { label: 'Character' }, { label: 'Droid' }, { label: 'Human' }, { label: 'Query' }, { label: 'TestInterface' }, { label: 'TestType' }, { label: 'TestUnion' }, ]); }); it('provides correct enum suggestions', () => { const result = testSuggestions('{ hero(episode: ', new Position(0, 16)); expect(result).toEqual([ { label: 'EMPIRE', detail: 'Episode' }, { label: 'JEDI', detail: 'Episode' }, { label: 'NEWHOPE', detail: 'Episode' }, ]); }); it('provides correct suggestions for declared variables upon typing $', () => { const result = testSuggestions( 'query($id: String, $ep: Episode!){ hero(episode: $ }', new Position(0, 51), ); expect(result).toEqual([ { label: '$ep', insertText: 'ep', detail: 'Episode' }, ]); }); it('provides correct suggestions for variables based on argument context', () => { const result = testSuggestions( 'query($id: String!, $episode: Episode!){ hero(episode: ', new Position(0, 55), ); expect(result).toEqual([ { label: '$episode', detail: 'Episode', insertText: '$episode' }, { label: 'EMPIRE', detail: 'Episode' }, { label: 'JEDI', detail: 'Episode' }, { label: 'NEWHOPE', detail: 'Episode' }, // no $id here, it's not compatible :P ]); }); it('provides fragment name suggestion', () => { const fragmentDef = 'fragment Foo on Human { id }'; // Test on concrete types expect( testSuggestions( `${fragmentDef} query { human(id: "1") { ...`, new Position(0, 57), ), ).toEqual([ { label: 'Foo', detail: 'Human', documentation: 'fragment Foo on Human', labelDetails: { detail: 'fragment Foo on Human' }, }, ]); expect( testSuggestions( `query { human(id: "1") { ... }} ${fragmentDef}`, new Position(0, 28), ), ).toEqual([ { label: 'Foo', detail: 'Human', documentation: 'fragment Foo on Human', labelDetails: { detail: 'fragment Foo on Human' }, }, ]); // Test on abstract type expect( testSuggestions( `${fragmentDef} query { hero(episode: JEDI) { ...`, new Position(0, 62), ), ).toEqual([ { label: 'Foo', detail: 'Human', documentation: 'fragment Foo on Human', labelDetails: { detail: 'fragment Foo on Human' }, }, ]); }); it('provides correct fragment name suggestions for external fragments', () => { const externalFragments = parse(` fragment CharacterDetails on Human { name } fragment CharacterDetails2 on Human { name } `).definitions as FragmentDefinitionNode[]; const result = testSuggestions( 'query { human(id: "1") { ... }}', new Position(0, 28), externalFragments, ); expect(result).toEqual([ { label: 'CharacterDetails', detail: 'Human', documentation: 'fragment CharacterDetails on Human', labelDetails: { detail: 'fragment CharacterDetails on Human' }, }, { label: 'CharacterDetails2', detail: 'Human', documentation: 'fragment CharacterDetails2 on Human', labelDetails: { detail: 'fragment CharacterDetails2 on Human' }, }, ]); }); it('provides correct directive suggestions', () => { expect(testSuggestions('{ test @ }', new Position(0, 8))).toEqual( expectedDirectiveSuggestions, ); expect(testSuggestions('{ test @', new Position(0, 8))).toEqual( expectedDirectiveSuggestions, ); expect( testSuggestions('{ aliasTest: test @ }', new Position(0, 19)), ).toEqual(expectedDirectiveSuggestions); expect(testSuggestions('query @', new Position(0, 7))).toEqual([]); }); it('provides correct directive field suggestions', () => { expect( testSuggestions('{ test @deprecated(', new Position(0, 19)), ).toEqual([ { command: suggestionCommand, label: 'reason', insertTextFormat: 2, insertText: 'reason: ', documentation: GraphQLDeprecatedDirective.args[0].description, labelDetails: { detail: ' String', }, }, ]); }); const inputArgs = [ { label: 'key', detail: 'String!', insertText: 'key: ', insertTextFormat: 2, command: suggestionCommand, }, { detail: 'InputType', label: 'obj', insertText: 'obj: {\n $1\n}', command: suggestionCommand, insertTextFormat: 2, }, { label: 'value', detail: 'Int', insertText: 'value: ', insertTextFormat: 2, command: suggestionCommand, }, ]; it('provides correct testInput type field suggestions', () => { expect( testSuggestions('{ inputTypeTest(args: {', new Position(0, 23)), ).toEqual(inputArgs); }); it('provides correct nested testInput type field suggestions', () => { expect( testSuggestions('{ inputTypeTest(args: { obj: {', new Position(0, 30)), ).toEqual(inputArgs); }); it('provides correct field name suggestion inside inline fragment', () => { expect( testSuggestions( 'fragment Foo on Character { ... on Human { }}', new Position(0, 42), [], { ignoreInsert: true, }, ), ).toEqual([ expectedResults.__typename, expectedResults.appearsIn, expectedResults.friends, { label: 'id', detail: 'String!' }, { label: 'name', detail: 'String' }, { label: 'secretBackstory', detail: 'String' }, ]); // Type-less inline fragment assumes the type automatically expect( testSuggestions( 'fragment Foo on Droid { ... { ', new Position(0, 30), [], { ignoreInsert: true, }, ), ).toEqual([ expectedResults.__typename, expectedResults.appearsIn, expectedResults.friends, { label: 'id', detail: 'String!' }, { label: 'instructions', detail: '[String]!' }, { label: 'name', detail: 'String' }, { label: 'primaryFunction', detail: 'String' }, { label: 'secretBackstory', detail: 'String' }, ]); }); }); describe('with SDL types', () => { it('provides correct initial keywords w/ graphqls', () => { expect( testSuggestions('', new Position(0, 0), [], { uri: 'schema.graphqls' }), ).toEqual([ { label: 'extend' }, { label: 'input' }, { label: 'interface' }, { label: 'scalar' }, { label: 'schema' }, { label: 'type' }, { label: 'union' }, ]); }); it('provides correct initial keywords w/out graphqls', () => { expect( testSuggestions('', new Position(0, 0), [], { uri: 'schema.graphql' }), ).toEqual([ { label: '{' }, { label: 'extend' }, { label: 'fragment' }, { label: 'input' }, { label: 'interface' }, { label: 'mutation' }, { label: 'query' }, { label: 'scalar' }, { label: 'schema' }, { label: 'subscription' }, { label: 'type' }, { label: 'union' }, ]); }); it('provides correct initial definition keywords', () => { expect( testSuggestions('type Type { field: String }\n\n', new Position(0, 31)), ).toEqual([ { label: 'extend' }, { label: 'input' }, { label: 'interface' }, { label: 'scalar' }, { label: 'schema' }, { label: 'type' }, { label: 'union' }, ]); }); it('provides correct extension keywords', () => { expect(testSuggestions('extend ', new Position(0, 7))).toEqual([ { label: 'input' }, { label: 'interface' }, { label: 'scalar' }, { label: 'schema' }, { label: 'type' }, { label: 'union' }, ]); }); it('provides scalars to be extended', () => { expect(testSuggestions('extend scalar ', new Position(0, 14))).toEqual([ { label: 'Boolean' }, { label: 'Int' }, { label: 'String' }, ]); }); it('provides object types to be extended', () => { expect(testSuggestions('extend type ', new Position(0, 12))).toEqual([ { label: 'Droid' }, { label: 'Human' }, { label: 'Query' }, { label: 'TestType' }, ]); }); it('does not provide object type names once extending a type', () => { expect( testSuggestions('extend type Query {', new Position(0, 19)), ).toEqual([]); }); it('provides interfaces to be extended', () => { expect(testSuggestions('extend interface ', new Position(0, 17))).toEqual( [ { label: 'AnotherInterface' }, { label: 'Character' }, { label: 'TestInterface' }, ], ); }); it('provides unions to be extended', () => { expect(testSuggestions('extend union ', new Position(0, 13))).toEqual([ { label: 'TestUnion' }, ]); }); it('provides enums to be extended', () => { expect(testSuggestions('extend enum ', new Position(0, 12))).toEqual([ { label: 'Episode' }, ]); }); it('provides input objects to be extended', () => { expect(testSuggestions('extend input ', new Position(0, 13))).toEqual([ { label: 'InputType' }, ]); }); it('provides correct directive suggestions on definitions', () => expect(testSuggestions('type Type @', new Position(0, 11))).toEqual([ { label: 'onAllDefs' }, ])); it('provides correct suggestions on object field w/ .graphqls', () => expect( testSuggestions('type Type {\n aField: s', new Position(0, 23), [], { uri: 'schema.graphqls', ignoreInsert: true, }), ).toEqual([ { label: 'Episode' }, { label: 'String' }, { label: 'TestInterface' }, { label: 'TestType' }, { label: 'TestUnion' }, ])); it('provides correct argument type suggestions on directive definitions', () => expect( testSuggestions( 'directive @skip(if: ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT', new Position(0, 19), [], { ignoreInsert: true, }, ), ).toEqual([ { label: 'Boolean' }, { label: 'Episode' }, { label: 'InputType' }, { label: 'Int' }, { label: 'String' }, ])); it('provides correct suggestions on object fields', () => expect( testSuggestions('type Type {\n aField: s', new Position(0, 23), [], { uri: 'schema.graphql', ignoreInsert: true, }), ).toEqual([ { label: 'Episode' }, { label: 'String' }, { label: 'TestInterface' }, { label: 'TestType' }, { label: 'TestUnion' }, ])); // TODO: shouldn't TestType and TestUnion be available here? it('provides correct filtered suggestions on object fields in regular SDL files', () => expect( testSuggestions('type Type {\n aField: s', new Position(0, 23), [], { uri: 'schema.graphql', ignoreInsert: true, }), ).toEqual([ { label: 'Episode' }, { label: 'String' }, { label: 'TestInterface' }, { label: 'TestType' }, { label: 'TestUnion' }, ])); it('provides correct unfiltered suggestions on object fields in regular SDL files', () => expect( testSuggestions('type Type {\n aField: ', new Position(0, 22), [], { uri: 'schema.graphql', ignoreInsert: true, }), ).toEqual([ { label: 'AnotherInterface' }, { label: 'Boolean' }, { label: 'Character' }, { label: 'Droid' }, { label: 'Episode' }, { label: 'Human' }, { label: 'Int' }, // TODO: maybe filter out types attached to top level schema? { label: 'Query' }, { label: 'String' }, { label: 'TestInterface' }, { label: 'TestType' }, { label: 'TestUnion' }, ])); it('provides correct suggestions on object fields that are arrays', () => expect( testSuggestions('type Type {\n aField: []', new Position(0, 25), [], { uri: 'schema.graphqls', ignoreInsert: true, }), ).toEqual([ { label: 'AnotherInterface' }, { label: 'Boolean' }, { label: 'Character' }, { label: 'Droid' }, { label: 'Episode' }, { label: 'Human' }, { label: 'Int' }, { label: 'Query' }, { label: 'String' }, { label: 'TestInterface' }, { label: 'TestType' }, { label: 'TestUnion' }, ])); it('provides correct suggestions on object fields that are arrays in SDL context', () => expect( testSuggestions('type Type {\n aField: []', new Position(0, 25), [], { uri: 'schema.graphql', ignoreInsert: true, }), ).toEqual([ { label: 'AnotherInterface' }, { label: 'Boolean' }, { label: 'Character' }, { label: 'Droid' }, { label: 'Episode' }, { label: 'Human' }, { label: 'Int' }, { label: 'Query' }, { label: 'String' }, { label: 'TestInterface' }, { label: 'TestType' }, { label: 'TestUnion' }, ])); it('provides correct suggestions on input object fields', () => expect( testSuggestions('input Type {\n aField: s', new Position(0, 23), [], { uri: 'schema.graphqls', }), ).toEqual([ { label: 'Episode' }, { label: 'String', documentation: GraphQLString.description }, ])); it('provides correct directive suggestions on args definitions', () => expect( testSuggestions('type Type { field(arg: String @', new Position(0, 31)), ).toEqual([ { label: 'deprecated', documentation: GraphQLDeprecatedDirective.description, }, { label: 'onAllDefs' }, { label: 'onArg' }, ])); it('provides correct interface suggestions when extending with an interface', () => expect( testSuggestions('type Type implements ', new Position(0, 20)), ).toEqual([ { label: 'AnotherInterface' }, { label: 'Character' }, { label: 'TestInterface' }, ])); it('provides correct interface suggestions when extending a type with multiple interfaces', () => expect( testSuggestions( 'type Type implements TestInterface & ', new Position(0, 37), ), ).toEqual([{ label: 'AnotherInterface' }, { label: 'Character' }])); it('provides correct interface suggestions when extending an interface with multiple interfaces', () => expect( testSuggestions( 'interface IExample implements TestInterface & ', new Position(0, 46), ), ).toEqual([{ label: 'AnotherInterface' }, { label: 'Character' }])); it('provides filtered interface suggestions when extending an interface with multiple interfaces', () => expect( testSuggestions( 'interface IExample implements TestInterface & Inter', new Position(0, 48), ), ).toEqual([{ label: 'AnotherInterface' }])); it('provides no interface suggestions when using implements and there are no & or { characters present', () => expect( testSuggestions( 'interface IExample implements TestInterface ', new Position(0, 44), ), ).toEqual([])); it('provides fragment completion after a list of interfaces to extend', () => expect( testSuggestions( 'interface IExample implements TestInterface & AnotherInterface @f', new Position(0, 65), ), ).toEqual([{ label: 'onAllDefs' }])); it('provides correct interface suggestions when extending an interface with an inline interface', () => expect( testSuggestions( 'interface A { id: String }\ninterface MyInterface implements ', new Position(1, 33), ), ).toEqual([ { label: 'A' }, { label: 'AnotherInterface' }, { label: 'Character' }, { label: 'TestInterface' }, ])); }); });