Mirror: TypeScript LSP plugin that finds GraphQL documents in your code and provides diagnostics, auto-complete and hover-information.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: only suggest fragment spreads within the correct type-bounds (#32)

* enforce the correct type on spread-suggestions

* vendor a few parts to streamline imports

* fixies

authored by

Jovi De Croock and committed by
GitHub
51eb6ab8 e2faba4b

+264 -37
+5
.changeset/proud-trainers-switch.md
··· 1 + --- 2 + '@0no-co/graphqlsp': patch 3 + --- 4 + 5 + Enforce the correct type on FragmentSpread suggestions
+1 -1
example/src/index.generated.ts
··· 169 169 selections: [ 170 170 { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 171 171 { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 172 + { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, 172 173 { 173 174 kind: 'FragmentSpread', 174 175 name: { kind: 'Name', value: 'pokemonFields' }, 175 176 }, 176 - { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, 177 177 ], 178 178 }, 179 179 },
+16 -10
example/src/index.ts
··· 6 6 pokemons { 7 7 id 8 8 name 9 - ...pokemonFields 10 9 __typename 10 + ...pokemonFields 11 11 } 12 12 } 13 13 14 14 ${PokemonFields} 15 - ` as typeof import('./index.generated').PokemonsDocument 15 + ` as typeof import('./index.generated').PokemonsDocument; 16 16 17 17 const client = createClient({ 18 18 url: '', 19 - }) 19 + }); 20 20 21 - client.query(PokemonsQuery).toPromise().then(result => { 22 - result.data?.pokemons; 23 - }) 21 + client 22 + .query(PokemonsQuery) 23 + .toPromise() 24 + .then(result => { 25 + result.data?.pokemons; 26 + }); 24 27 25 28 const PokemonQuery = gql` 26 29 query Pokemon($id: ID!) { ··· 30 33 __typename 31 34 } 32 35 } 33 - ` as typeof import('./index.generated').PokemonDocument 36 + ` as typeof import('./index.generated').PokemonDocument; 34 37 35 - client.query(PokemonQuery, { id: '' }).toPromise().then(result => { 36 - result.data?.pokemon; 37 - }) 38 + client 39 + .query(PokemonQuery, { id: '' }) 40 + .toPromise() 41 + .then(result => { 42 + result.data?.pokemon; 43 + });
+35 -26
src/index.ts
··· 15 15 getAutocompleteSuggestions, 16 16 getDiagnostics, 17 17 Diagnostic, 18 + getTokenAtPosition, 19 + getTypeInfo, 18 20 } from 'graphql-language-service'; 19 21 import { 20 22 parse, ··· 22 24 FragmentDefinitionNode, 23 25 OperationDefinitionNode, 24 26 } from 'graphql'; 25 - import fs from 'fs'; 26 27 27 28 import { Cursor } from './cursor'; 28 29 import { loadSchema } from './getSchema'; ··· 31 32 findAllTaggedTemplateNodes, 32 33 findNode, 33 34 getSource, 35 + getSuggestionsForFragmentSpread, 34 36 isFileDirty, 35 37 } from './utils'; 36 38 import { resolveTemplate } from './resolve'; ··· 314 316 ) as Array<FragmentDefinitionNode>; 315 317 } catch (e) {} 316 318 319 + const cursor = new Cursor(foundToken.line, foundToken.start); 317 320 const suggestions = getAutocompleteSuggestions( 318 321 schema.current, 319 322 text, 320 - new Cursor(foundToken.line, foundToken.start), 321 - undefined, 323 + cursor 324 + ); 325 + 326 + const token = getTokenAtPosition(text, cursor); 327 + const spreadSuggestions = getSuggestionsForFragmentSpread( 328 + token, 329 + getTypeInfo(schema.current, token.state), 330 + schema.current, 331 + text, 322 332 fragments 323 333 ); 324 334 ··· 327 337 isMemberCompletion: false, 328 338 isNewIdentifierLocation: false, 329 339 entries: [ 330 - ...suggestions.map( 331 - suggestion => 332 - ({ 333 - kind: ScriptElementKind.variableElement, 334 - name: suggestion.label, 335 - kindModifiers: 'declare', 336 - sortText: suggestion.sortText || '0', 337 - labelDetails: { 338 - detail: 339 - ' ' + suggestion.documentation || 340 - suggestion.labelDetails?.detail || 341 - suggestion.type, 342 - description: 343 - ' ' + suggestion.labelDetails?.description || 344 - suggestion.documentation, 345 - }, 346 - } as CompletionEntry) 347 - ), 348 - ...fragments.map(fragment => ({ 340 + ...suggestions.map(suggestion => ({ 341 + ...suggestion, 342 + kind: ScriptElementKind.variableElement, 343 + name: suggestion.label, 344 + kindModifiers: 'declare', 345 + sortText: suggestion.sortText || '0', 346 + labelDetails: { 347 + detail: 348 + suggestion.documentation || 349 + suggestion.labelDetails?.detail || 350 + suggestion.type?.toString(), 351 + description: 352 + suggestion.labelDetails?.description || 353 + suggestion.documentation, 354 + }, 355 + })), 356 + ...spreadSuggestions.map(suggestion => ({ 357 + ...suggestion, 349 358 kind: ScriptElementKind.variableElement, 350 - name: fragment.name.value, 351 - insertText: '...' + fragment.name.value, 359 + name: suggestion.label, 360 + insertText: '...' + suggestion.label, 352 361 kindModifiers: 'declare', 353 362 sortText: '0', 354 363 labelDetails: { 355 - detail: ' on type ' + fragment.typeCondition.name.value, 356 - description: ' on type ' + fragment.typeCondition.name.value, 364 + detail: suggestion.documentation, 365 + description: suggestion.documentation, 357 366 }, 358 367 })), 359 368 ...originalCompletions.entries,
+207
src/utils.ts
··· 4 4 isTaggedTemplateExpression, 5 5 } from 'typescript'; 6 6 import fs from 'fs'; 7 + import { 8 + CompletionItem, 9 + CompletionItemKind, 10 + ContextToken, 11 + ContextTokenUnion, 12 + Maybe, 13 + RuleKinds, 14 + getDefinitionState, 15 + } from 'graphql-language-service'; 16 + import { 17 + FragmentDefinitionNode, 18 + GraphQLArgument, 19 + GraphQLCompositeType, 20 + GraphQLDirective, 21 + GraphQLEnumValue, 22 + GraphQLField, 23 + GraphQLInputFieldMap, 24 + GraphQLInterfaceType, 25 + GraphQLObjectType, 26 + GraphQLSchema, 27 + GraphQLType, 28 + doTypesOverlap, 29 + isCompositeType, 30 + } from 'graphql'; 7 31 8 32 export function isFileDirty(fileName: string, source: ts.SourceFile) { 9 33 const contents = fs.readFileSync(fileName, 'utf-8'); ··· 54 78 55 79 return source; 56 80 } 81 + 82 + /** 83 + * This part is vendored from https://github.com/graphql/graphiql/blob/main/packages/graphql-language-service/src/interface/autocompleteUtils.ts#L97 84 + */ 85 + export type CompletionItemBase = { 86 + label: string; 87 + isDeprecated?: boolean; 88 + }; 89 + 90 + // Create the expected hint response given a possible list and a token 91 + export function hintList<T extends CompletionItemBase>( 92 + token: ContextTokenUnion, 93 + list: Array<T> 94 + ): Array<T> { 95 + return filterAndSortList(list, normalizeText(token.string)); 96 + } 97 + 98 + // Given a list of hint entries and currently typed text, sort and filter to 99 + // provide a concise list. 100 + function filterAndSortList<T extends CompletionItemBase>( 101 + list: Array<T>, 102 + text: string 103 + ): Array<T> { 104 + if (!text) { 105 + return filterNonEmpty<T>(list, entry => !entry.isDeprecated); 106 + } 107 + 108 + const byProximity = list.map(entry => ({ 109 + proximity: getProximity(normalizeText(entry.label), text), 110 + entry, 111 + })); 112 + 113 + return filterNonEmpty( 114 + filterNonEmpty(byProximity, pair => pair.proximity <= 2), 115 + pair => !pair.entry.isDeprecated 116 + ) 117 + .sort( 118 + (a, b) => 119 + (a.entry.isDeprecated ? 1 : 0) - (b.entry.isDeprecated ? 1 : 0) || 120 + a.proximity - b.proximity || 121 + a.entry.label.length - b.entry.label.length 122 + ) 123 + .map(pair => pair.entry); 124 + } 125 + 126 + // Filters the array by the predicate, unless it results in an empty array, 127 + // in which case return the original array. 128 + function filterNonEmpty<T>( 129 + array: Array<T>, 130 + predicate: (entry: T) => boolean 131 + ): Array<T> { 132 + const filtered = array.filter(predicate); 133 + return filtered.length === 0 ? array : filtered; 134 + } 135 + 136 + function normalizeText(text: string): string { 137 + return text.toLowerCase().replace(/\W/g, ''); 138 + } 139 + 140 + // Determine a numeric proximity for a suggestion based on current text. 141 + function getProximity(suggestion: string, text: string): number { 142 + // start with lexical distance 143 + let proximity = lexicalDistance(text, suggestion); 144 + if (suggestion.length > text.length) { 145 + // do not penalize long suggestions. 146 + proximity -= suggestion.length - text.length - 1; 147 + // penalize suggestions not starting with this phrase 148 + proximity += suggestion.indexOf(text) === 0 ? 0 : 0.5; 149 + } 150 + return proximity; 151 + } 152 + 153 + /** 154 + * Computes the lexical distance between strings A and B. 155 + * 156 + * The "distance" between two strings is given by counting the minimum number 157 + * of edits needed to transform string A into string B. An edit can be an 158 + * insertion, deletion, or substitution of a single character, or a swap of two 159 + * adjacent characters. 160 + * 161 + * This distance can be useful for detecting typos in input or sorting 162 + * 163 + * @param {string} a 164 + * @param {string} b 165 + * @return {int} distance in number of edits 166 + */ 167 + function lexicalDistance(a: string, b: string): number { 168 + let i; 169 + let j; 170 + const d = []; 171 + const aLength = a.length; 172 + const bLength = b.length; 173 + 174 + for (i = 0; i <= aLength; i++) { 175 + d[i] = [i]; 176 + } 177 + 178 + for (j = 1; j <= bLength; j++) { 179 + d[0][j] = j; 180 + } 181 + 182 + for (i = 1; i <= aLength; i++) { 183 + for (j = 1; j <= bLength; j++) { 184 + const cost = a[i - 1] === b[j - 1] ? 0 : 1; 185 + 186 + d[i][j] = Math.min( 187 + d[i - 1][j] + 1, 188 + d[i][j - 1] + 1, 189 + d[i - 1][j - 1] + cost 190 + ); 191 + 192 + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { 193 + d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + cost); 194 + } 195 + } 196 + } 197 + 198 + return d[aLength][bLength]; 199 + } 200 + 201 + export type AllTypeInfo = { 202 + type: Maybe<GraphQLType>; 203 + parentType: Maybe<GraphQLType>; 204 + inputType: Maybe<GraphQLType>; 205 + directiveDef: Maybe<GraphQLDirective>; 206 + fieldDef: Maybe<GraphQLField<any, any>>; 207 + enumValue: Maybe<GraphQLEnumValue>; 208 + argDef: Maybe<GraphQLArgument>; 209 + argDefs: Maybe<GraphQLArgument[]>; 210 + objectFieldDefs: Maybe<GraphQLInputFieldMap>; 211 + interfaceDef: Maybe<GraphQLInterfaceType>; 212 + objectTypeDef: Maybe<GraphQLObjectType>; 213 + }; 214 + 215 + /** 216 + * This is vendored from https://github.com/graphql/graphiql/blob/main/packages/graphql-language-service/src/interface/getAutocompleteSuggestions.ts#L779 217 + */ 218 + export function getSuggestionsForFragmentSpread( 219 + token: ContextToken, 220 + typeInfo: AllTypeInfo, 221 + schema: GraphQLSchema, 222 + queryText: string, 223 + fragments: FragmentDefinitionNode[] 224 + ): Array<CompletionItem> { 225 + if (!queryText) { 226 + return []; 227 + } 228 + 229 + const typeMap = schema.getTypeMap(); 230 + const defState = getDefinitionState(token.state); 231 + 232 + // Filter down to only the fragments which may exist here. 233 + const relevantFrags = fragments.filter( 234 + frag => 235 + // Only include fragments with known types. 236 + typeMap[frag.typeCondition.name.value] && 237 + // Only include fragments which are not cyclic. 238 + !( 239 + defState && 240 + defState.kind === RuleKinds.FRAGMENT_DEFINITION && 241 + defState.name === frag.name.value 242 + ) && 243 + // Only include fragments which could possibly be spread here. 244 + isCompositeType(typeInfo.parentType) && 245 + isCompositeType(typeMap[frag.typeCondition.name.value]) && 246 + doTypesOverlap( 247 + schema, 248 + typeInfo.parentType, 249 + typeMap[frag.typeCondition.name.value] as GraphQLCompositeType 250 + ) 251 + ); 252 + 253 + return hintList( 254 + token, 255 + relevantFrags.map(frag => ({ 256 + label: frag.name.value, 257 + detail: String(typeMap[frag.typeCondition.name.value]), 258 + documentation: `fragment ${frag.name.value} on ${frag.typeCondition.name.value}`, 259 + kind: CompletionItemKind.Field, 260 + type: typeMap[frag.typeCondition.name.value], 261 + })) 262 + ); 263 + }