Skip to content

Commit

Permalink
feat: interfaces implementing interfaces for the LSP (#1742)
Browse files Browse the repository at this point in the history
- support `implements MyInterface &` syntax in the parser & interface
- works for both types and interfaces
- more integration tests for parser & autocomplete
- interfaces defined inline in the file and not yet in the schema will still appear for completion
  • Loading branch information
acao authored Dec 28, 2020
1 parent d81db0c commit c4cba85
Show file tree
Hide file tree
Showing 9 changed files with 302 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,16 @@ input InputType {
value: Int = 42
}

type TestType {
testField: String
interface TestInterface {
testField: String!
}

interface AnotherInterface implements TestInterface {
testField: String!
}

type TestType implements TestInterface & AnotherInterface {
testField: String!
}

type Query {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ describe('getAutocompleteSuggestions', () => {
)
.sort((a, b) => a.label.localeCompare(b.label))
.map(suggestion => {
const response = { label: suggestion.label };
const response = { label: suggestion.label } as CompletionItem;
if (suggestion.detail) {
Object.assign(response, {
detail: String(suggestion.detail),
});
response.detail = String(suggestion.detail);
}
// if(suggestion.documentation) {
// response.documentation = String(suggestion.documentation)
// }
return response;
});
}
Expand Down Expand Up @@ -217,10 +218,12 @@ query name {
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' },
]);
});
Expand Down Expand Up @@ -331,4 +334,73 @@ query name {
{ 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' },
{ label: 'TestInterface' },
]));
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' },
{ label: 'TestInterface' },
]));
it('provides filtered interface suggestions when extending an interface with multiple interfaces', () =>
expect(
testSuggestions('interface IExample implements I', new Position(0, 48)),
).toEqual([
{ label: 'AnotherInterface' },
// TODO: this shouldn't be here - must find way to
// track base interface name so we can filter it from inline suggestions
// on NamedType kind
{ label: 'IExample' },
{ label: 'TestInterface' },
]));
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' },
]));
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
visit,
VariableDefinitionNode,
GraphQLNamedType,
isInterfaceType,
GraphQLInterfaceType,
} from 'graphql';

import {
Expand Down Expand Up @@ -84,6 +86,7 @@ export function getAutocompleteSuggestions(

const kind = state.kind;
const step = state.step;

const typeInfo = getTypeInfo(schema, token.state);
// Definition kinds
if (kind === RuleKinds.DOCUMENT) {
Expand All @@ -96,13 +99,21 @@ export function getAutocompleteSuggestions(
]);
}

if (
kind === RuleKinds.IMPLEMENTS ||
(kind === RuleKinds.NAMED_TYPE &&
state.prevState?.kind === RuleKinds.IMPLEMENTS)
) {
return getSuggestionsForImplements(token, state, schema, queryText);
}

// Field names
if (
kind === RuleKinds.SELECTION_SET ||
kind === RuleKinds.FIELD ||
kind === RuleKinds.ALIASED_FIELD
) {
return getSuggestionsForFieldNames(token, typeInfo, schema, kind);
return getSuggestionsForFieldNames(token, typeInfo, schema);
}

// Argument names
Expand Down Expand Up @@ -154,7 +165,7 @@ export function getAutocompleteSuggestions(
(kind === RuleKinds.OBJECT_FIELD && step === 2) ||
(kind === RuleKinds.ARGUMENT && step === 2)
) {
return getSuggestionsForInputValues(token, typeInfo, kind as string);
return getSuggestionsForInputValues(token, typeInfo);
}

// complete for all variables available in the query
Expand Down Expand Up @@ -240,8 +251,6 @@ function getSuggestionsForFieldNames(
token: ContextToken,
typeInfo: AllTypeInfo,
schema: GraphQLSchema,
// kind: RuleKind.SelectionSet | RuleKind.Field | RuleKind.AliasedField,
_kind: string,
): Array<CompletionItem> {
if (typeInfo.parentType) {
const parentType = typeInfo.parentType;
Expand Down Expand Up @@ -280,7 +289,6 @@ function getSuggestionsForFieldNames(
function getSuggestionsForInputValues(
token: ContextToken,
typeInfo: AllTypeInfo,
_kind: string,
): CompletionItem[] {
const namedInputType = getNamedType(typeInfo.inputType as GraphQLType);
if (namedInputType instanceof GraphQLEnumType) {
Expand Down Expand Up @@ -320,6 +328,58 @@ function getSuggestionsForInputValues(
return [];
}

function getSuggestionsForImplements(
token: ContextToken,
tokenState: State,
schema: GraphQLSchema,
documentText: string,
): Array<CompletionItem> {
// exit empty if we need an &
if (tokenState.needsSeperator) {
return [];
}
// gather inline interfaces so we can reference them.
// we don't know if they're part of the schema yet,
// but if they're defined in the same file that's a good sign
const inlineInterfaces: Set<string> = new Set();
runOnlineParser(documentText, (_, state: State) => {
if (state.name && state.kind === RuleKinds.INTERFACE_DEF) {
inlineInterfaces.add(<string>state.name);
}
});
const typeMap = schema.getTypeMap();
const schemaInterfaces = objectValues(typeMap).filter(isInterfaceType);
const possibleInterfaces = schemaInterfaces.concat(
[...inlineInterfaces]
// don't show the interface we're extending
.filter(v => v !== tokenState.prevState?.name)
.map(name => ({ name } as GraphQLInterfaceType)),
);

return hintList(
token,
possibleInterfaces.map(type => {
const result = {
label: type.name,
kind: CompletionItemKind.Interface,
} as CompletionItem;
if (type?.description) {
result.documentation = type.description;
}
// TODO: should we report what an interface implements in suggestion.details?
// result.detail = 'Interface'
// const interfaces = type.astNode?.interfaces;
// if (interfaces && interfaces.length > 0) {
// result.detail += ` (implements ${interfaces
// .map(i => i.name.value)
// .join(' & ')})`;
// }

return result;
}),
);
}

function getSuggestionsForFragmentTypeConditions(
token: ContextToken,
typeInfo: AllTypeInfo,
Expand Down Expand Up @@ -352,7 +412,7 @@ function getSuggestionsForFragmentTypeConditions(
}
return hintList(
token,
possibleTypes.map((type: GraphQLType) => {
possibleTypes.map(type => {
const namedType = getNamedType(type);
return {
label: String(type),
Expand Down Expand Up @@ -677,6 +737,10 @@ export function getTypeInfo(
case RuleKinds.DIRECTIVE:
directiveDef = state.name ? schema.getDirective(state.name) : null;
break;
// TODO: here is where we can begin to solve the issue of completion for the interface
// you're extending
// case RuleKinds.IMPLEMENTS:
// break;
case RuleKinds.ARGUMENTS:
if (!state.prevState) {
argDefs = null;
Expand Down
40 changes: 20 additions & 20 deletions packages/graphql-language-service-parser/src/CharacterStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,21 @@
import { TokenPattern, CharacterStreamInterface } from './types';

export default class CharacterStream implements CharacterStreamInterface {
_start: number;
_pos: number;
_sourceText: string;
private _start: number;
private _pos: number;
private _sourceText: string;

constructor(sourceText: string) {
this._start = 0;
this._pos = 0;
this._sourceText = sourceText;
}

getStartOfToken = (): number => this._start;
public getStartOfToken = (): number => this._start;

getCurrentPosition = (): number => this._pos;
public getCurrentPosition = (): number => this._pos;

_testNextCharacter(pattern: TokenPattern): boolean {
private _testNextCharacter(pattern: TokenPattern): boolean {
const character = this._sourceText.charAt(this._pos);
let isMatched = false;
if (typeof pattern === 'string') {
Expand All @@ -48,23 +48,23 @@ export default class CharacterStream implements CharacterStreamInterface {
return isMatched;
}

eol = (): boolean => this._sourceText.length === this._pos;
public eol = (): boolean => this._sourceText.length === this._pos;

sol = (): boolean => this._pos === 0;
public sol = (): boolean => this._pos === 0;

peek = (): string | null => {
public peek = (): string | null => {
return this._sourceText.charAt(this._pos)
? this._sourceText.charAt(this._pos)
: null;
};

next = (): string => {
public next = (): string => {
const char = this._sourceText.charAt(this._pos);
this._pos++;
return char;
};

eat = (pattern: TokenPattern): string | undefined => {
public eat = (pattern: TokenPattern): string | undefined => {
const isMatched = this._testNextCharacter(pattern);
if (isMatched) {
this._start = this._pos;
Expand All @@ -74,7 +74,7 @@ export default class CharacterStream implements CharacterStreamInterface {
return undefined;
};

eatWhile = (match: TokenPattern): boolean => {
public eatWhile = (match: TokenPattern): boolean => {
let isMatched = this._testNextCharacter(match);
let didEat = false;

Expand All @@ -93,17 +93,17 @@ export default class CharacterStream implements CharacterStreamInterface {
return didEat;
};

eatSpace = (): boolean => this.eatWhile(/[\s\u00a0]/);
public eatSpace = (): boolean => this.eatWhile(/[\s\u00a0]/);

skipToEnd = (): void => {
public skipToEnd = (): void => {
this._pos = this._sourceText.length;
};

skipTo = (position: number): void => {
public skipTo = (position: number): void => {
this._pos = position;
};

match = (
public match = (
pattern: TokenPattern,
consume: boolean | null | undefined = true,
caseFold: boolean | null | undefined = false,
Expand Down Expand Up @@ -143,13 +143,13 @@ export default class CharacterStream implements CharacterStreamInterface {
return false;
};

backUp = (num: number): void => {
public backUp = (num: number): void => {
this._pos -= num;
};

column = (): number => this._pos;
public column = (): number => this._pos;

indentation = (): number => {
public indentation = (): number => {
const match = this._sourceText.match(/\s*/);
let indent = 0;
if (match && match.length !== 0) {
Expand All @@ -168,5 +168,5 @@ export default class CharacterStream implements CharacterStreamInterface {
return indent;
};

current = (): string => this._sourceText.slice(this._start, this._pos);
public current = (): string => this._sourceText.slice(this._start, this._pos);
}
Loading

0 comments on commit c4cba85

Please sign in to comment.