graphql-language-service-interface
Version:
Interface to the GraphQL Language Service
580 lines (529 loc) • 17.6 kB
text/typescript
/**
* 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 { CompletionItem } from 'graphql-language-service-types';
import fs, { readSync } from 'fs';
import {
buildSchema,
FragmentDefinitionNode,
GraphQLSchema,
parse,
version as graphQLVersion,
} from 'graphql';
import { Position } from 'graphql-language-service-utils';
import path from 'path';
import {
getAutocompleteSuggestions,
SuggestionCommand,
} from '../getAutocompleteSuggestions';
const commonInsert = {
insertTextFormat: 2,
command: SuggestionCommand,
};
const expectedResults = {
droid: {
...commonInsert,
label: 'droid',
detail: 'Droid',
insertText: `droid {\n $1\n}`,
},
hero: {
...commonInsert,
label: 'hero',
detail: 'Character',
insertText: `hero {\n $1\n}`,
},
human: {
...commonInsert,
label: 'human',
detail: 'Human',
insertText: `human {\n $1\n}`,
},
inputTypeTest: {
...commonInsert,
label: 'inputTypeTest',
detail: 'TestType',
insertText: `inputTypeTest {\n $1\n}`,
},
appearsIn: {
label: 'appearsIn',
detail: '[Episode]',
},
friends: {
...commonInsert,
label: 'friends',
detail: '[Character]',
insertText: `friends {\n $1\n}`,
},
};
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 soreted autocomplete suggestions in an increasing order.
function testSuggestions(
query: string,
point: Position,
externalFragments?: FragmentDefinitionNode[],
): Array<CompletionItem> {
return getAutocompleteSuggestions(
schema,
query,
point,
null,
externalFragments,
)
.filter(
field => !['__schema', '__type'].some(name => name === 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) {
response.insertText = suggestion.insertText;
}
if (suggestion.insertTextFormat) {
response.insertTextFormat = suggestion.insertTextFormat;
}
if (suggestion.command) {
response.command = suggestion.command;
}
return response;
});
}
describe('with Operation types', () => {
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: '6__schema',
label: '__schema',
detail: '__Schema!',
},
]);
});
it('provides correct initial keywords', () => {
expect(testSuggestions('', new Position(0, 0))).toEqual([
{ label: '{' },
{ label: 'fragment' },
{ label: 'mutation' },
{ label: 'query' },
{ label: 'subscription' },
]);
expect(testSuggestions('q', new Position(0, 1))).toEqual([
{ label: '{' },
{ label: 'query' },
]);
});
it('provides correct suggestions at where the cursor is', () => {
// Below should provide initial keywords
expect(testSuggestions(' {}', 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))).toEqual([
{ label: '__typename', detail: 'String!' },
expectedResults.droid,
expectedResults.hero,
expectedResults.human,
expectedResults.inputTypeTest,
]);
// Test for query text with empty lines
expect(
testSuggestions(
`
query name {
...testFragment
}
`,
new Position(2, 0),
),
).toEqual([
{ label: '__typename', detail: 'String!' },
expectedResults.droid,
expectedResults.hero,
expectedResults.human,
expectedResults.inputTypeTest,
]);
});
it('provides correct field name suggestions', () => {
const result = testSuggestions('{ ', new Position(0, 2));
expect(result).toEqual([
{ label: '__typename', detail: 'String!' },
expectedResults.droid,
expectedResults.hero,
expectedResults.human,
expectedResults.inputTypeTest,
]);
});
it('provides correct field name suggestions after filtered', () => {
const result = testSuggestions('{ h ', new Position(0, 3));
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),
);
expect(result).toEqual([
{ label: '__typename', detail: 'String!' },
expectedResults.appearsIn,
expectedResults.friends,
{ label: 'id', detail: 'String!' },
{ label: 'name', detail: 'String' },
{ label: 'secretBackstory', detail: 'String' },
]);
});
it('provides correct field suggestions for fragments', () => {
const result = testSuggestions(
'fragment test on Human { ',
new Position(0, 25),
);
expect(result).toEqual([
{ label: '__typename', detail: 'String!' },
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',
detail: 'String!',
insertText: 'id: ',
command: suggestionCommand,
},
]);
});
it('provides correct argument suggestions when using aliases', () => {
const result = testSuggestions(
'{ aliasTest: human( ',
new Position(0, 20),
);
expect(result).toEqual([
{
label: 'id',
detail: 'String!',
command: suggestionCommand,
insertText: 'id: ',
},
]);
});
it('provides correct input type suggestions', () => {
const result = testSuggestions(
'query($exampleVariable: ) { ',
new Position(0, 24),
);
expect(result).toEqual([
{ label: '__DirectiveLocation' },
{ label: '__TypeKind' },
{ label: 'Boolean' },
{ label: 'Episode' },
{ label: 'InputType' },
{ label: 'Int' },
{ label: 'String' },
]);
});
it('provides filtered input type suggestions', () => {
const result = testSuggestions(
'query($exampleVariable: In) { ',
new Position(0, 26),
);
expect(result).toEqual([
{ label: '__DirectiveLocation' },
{ label: '__TypeKind' },
{ label: 'InputType' },
{ label: 'Int' },
{ label: 'String' },
]);
});
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' },
]);
});
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: 'EMPIRE', detail: 'Episode' },
{ label: 'episode', detail: 'Episode', insertText: '$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' }]);
expect(
testSuggestions(
`query { human(id: "1") { ... }} ${fragmentDef}`,
new Position(0, 28),
),
).toEqual([{ label: 'Foo', detail: 'Human' }]);
// Test on abstract type
expect(
testSuggestions(
`${fragmentDef} query { hero(episode: JEDI) { ...`,
new Position(0, 62),
),
).toEqual([{ label: 'Foo', detail: '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' },
{ label: 'CharacterDetails2', detail: 'Human' },
]);
});
const expectedDirectiveSuggestions = [
{ label: 'include' },
{ label: 'skip' },
];
// TODO: remove this once defer and stream are merged to `graphql`
if (graphQLVersion.includes('defer')) {
expectedDirectiveSuggestions.push({ label: 'stream' });
}
expectedDirectiveSuggestions.push({ label: 'test' });
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 testInput suggestions', () => {
expect(
testSuggestions('{ inputTypeTest(args: {', new Position(0, 23)),
).toEqual([
{ label: 'key', detail: 'String!' },
{ label: 'value', detail: 'Int' },
]);
});
it('provides correct field name suggestion inside inline fragment', () => {
expect(
testSuggestions(
'fragment Foo on Character { ... on Human { }}',
new Position(0, 42),
),
).toEqual([
{ label: '__typename', detail: 'String!' },
expectedResults.appearsIn,
expectedResults.friends,
{ label: 'id', detail: 'String!' },
{ label: 'name', detail: 'String' },
{ label: 'secretBackstory', detail: 'String' },
]);
// Typeless inline fragment assumes the type automatically
expect(
testSuggestions('fragment Foo on Droid { ... { ', new Position(0, 30)),
).toEqual([
{ label: '__typename', detail: 'String!' },
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 directive suggestions on definitions', () =>
expect(testSuggestions('type Type @', new Position(0, 11))).toEqual([
{ label: 'onAllDefs' },
]));
it('provides correct suggestions on object fields', () =>
expect(
testSuggestions(`type Type {\n aField: s`, new Position(0, 23)),
).toEqual([{ label: 'Episode' }, { label: 'String' }]));
it('provides correct suggestions on input object fields', () =>
expect(
testSuggestions(`input Type {\n aField: s`, new Position(0, 23)),
).toEqual([{ label: 'Episode' }, { label: 'String' }]));
it('provides correct directive suggestions on args definitions', () =>
expect(
testSuggestions('type Type { field(arg: String @', new Position(0, 31)),
).toEqual([
{ label: 'deprecated' },
{ 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' },
]));
});
});