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.

feat: provide diagnostic for unused colocated fragments (#42)

* provide message for unused fragments

* remove existing imports from diagnostics

* updates

* update readme

* add todo

* Update .changeset/sour-feet-hear.md

authored by

Jovi De Croock and committed by
GitHub
1fbc8ebb c7924015

+280 -152
+6
.changeset/sour-feet-hear.md
··· 1 + --- 2 + '@0no-co/graphqlsp': minor 3 + --- 4 + 5 + Add a `message` diagnostic when we see an import from a file that has `fragment` exports we'll warn you when they are not imported, this because of the assumption that to use this file one would have to adhere to the data requirements of said file. 6 + You can choose to disable this setting by setting `shouldCheckForColocatedFragments` to `false`
+2 -2
.gitattributes
··· 1 1 * text=auto 2 - ./**/*.generated.ts linguist-generated 3 - ./**/*.graphql linguist-generated 2 + **/*.generated.ts linguist-generated 3 + **/*.graphql linguist-generated
+1
README.md
··· 10 10 - Diagnostics for adding fields that don't exist, are deprecated, missmatched argument types, ... 11 11 - Auto-complete inside your editor for fields 12 12 - When you save it will generate `typed-document-nodes` for your documents and cast them to the correct type 13 + - Will warn you when you are importing from a file that is exporting fragments that you're not using 13 14 14 15 ## Installation 15 16
+103 -24
packages/example/src/Pokemon.generated.ts
··· 1 - import * as Types from '../__generated__/baseGraphQLSP'; 2 1 import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 3 - export type FieldsFragment = { 2 + export type Maybe<T> = T | null; 3 + export type InputMaybe<T> = Maybe<T>; 4 + export type Exact<T extends { [key: string]: unknown }> = { 5 + [K in keyof T]: T[K]; 6 + }; 7 + export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { 8 + [SubKey in K]?: Maybe<T[SubKey]>; 9 + }; 10 + export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { 11 + [SubKey in K]: Maybe<T[SubKey]>; 12 + }; 13 + /** All built-in and custom scalars, mapped to their actual values */ 14 + export type Scalars = { 15 + ID: string; 16 + String: string; 17 + Boolean: boolean; 18 + Int: number; 19 + Float: number; 20 + }; 21 + 22 + /** Elemental property associated with either a Pokémon or one of their moves. */ 23 + export type PokemonType = 24 + | 'Grass' 25 + | 'Poison' 26 + | 'Fire' 27 + | 'Flying' 28 + | 'Water' 29 + | 'Bug' 30 + | 'Normal' 31 + | 'Electric' 32 + | 'Ground' 33 + | 'Fairy' 34 + | 'Fighting' 35 + | 'Psychic' 36 + | 'Rock' 37 + | 'Steel' 38 + | 'Ice' 39 + | 'Ghost' 40 + | 'Dragon' 41 + | 'Dark'; 42 + 43 + /** Move a Pokémon can perform with the associated damage and type. */ 44 + export type Attack = { 45 + __typename?: 'Attack'; 46 + name?: Maybe<Scalars['String']>; 47 + type?: Maybe<PokemonType>; 48 + damage?: Maybe<Scalars['Int']>; 49 + }; 50 + 51 + /** Requirement that prevents an evolution through regular means of levelling up. */ 52 + export type EvolutionRequirement = { 53 + __typename?: 'EvolutionRequirement'; 54 + amount?: Maybe<Scalars['Int']>; 55 + name?: Maybe<Scalars['String']>; 56 + }; 57 + 58 + export type PokemonDimension = { 59 + __typename?: 'PokemonDimension'; 60 + minimum?: Maybe<Scalars['String']>; 61 + maximum?: Maybe<Scalars['String']>; 62 + }; 63 + 64 + export type AttacksConnection = { 65 + __typename?: 'AttacksConnection'; 66 + fast?: Maybe<Array<Maybe<Attack>>>; 67 + special?: Maybe<Array<Maybe<Attack>>>; 68 + }; 69 + 70 + export type Pokemon = { 4 71 __typename?: 'Pokemon'; 5 - classification?: string | null; 6 - id: string; 72 + id: Scalars['ID']; 73 + name: Scalars['String']; 74 + classification?: Maybe<Scalars['String']>; 75 + types?: Maybe<Array<Maybe<PokemonType>>>; 76 + resistant?: Maybe<Array<Maybe<PokemonType>>>; 77 + weaknesses?: Maybe<Array<Maybe<PokemonType>>>; 78 + evolutionRequirements?: Maybe<Array<Maybe<EvolutionRequirement>>>; 79 + weight?: Maybe<PokemonDimension>; 80 + height?: Maybe<PokemonDimension>; 81 + attacks?: Maybe<AttacksConnection>; 82 + /** Likelihood of an attempt to catch a Pokémon to fail. */ 83 + fleeRate?: Maybe<Scalars['Float']>; 84 + /** Maximum combat power a Pokémon may achieve at max level. */ 85 + maxCP?: Maybe<Scalars['Int']>; 86 + /** Maximum health points a Pokémon may achieve at max level. */ 87 + maxHP?: Maybe<Scalars['Int']>; 88 + evolutions?: Maybe<Array<Maybe<Pokemon>>>; 89 + }; 90 + 91 + export type Query = { 92 + __typename?: 'Query'; 93 + /** List out all Pokémon, optionally in pages */ 94 + pokemons?: Maybe<Array<Maybe<Pokemon>>>; 95 + /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ 96 + pokemon?: Maybe<Pokemon>; 97 + }; 98 + 99 + export type QueryPokemonsArgs = { 100 + limit?: InputMaybe<Scalars['Int']>; 101 + skip?: InputMaybe<Scalars['Int']>; 102 + }; 103 + 104 + export type QueryPokemonArgs = { 105 + id: Scalars['ID']; 7 106 }; 8 107 9 108 export type PokemonFieldsFragment = { ··· 12 111 name: string; 13 112 }; 14 113 15 - export const FieldsFragmentDoc = { 16 - kind: 'Document', 17 - definitions: [ 18 - { 19 - kind: 'FragmentDefinition', 20 - name: { kind: 'Name', value: 'fields' }, 21 - typeCondition: { 22 - kind: 'NamedType', 23 - name: { kind: 'Name', value: 'Pokemon' }, 24 - }, 25 - selectionSet: { 26 - kind: 'SelectionSet', 27 - selections: [ 28 - { kind: 'Field', name: { kind: 'Name', value: 'classification' } }, 29 - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 30 - ], 31 - }, 32 - }, 33 - ], 34 - } as unknown as DocumentNode<FieldsFragment, unknown>; 35 114 export const PokemonFieldsFragmentDoc = { 36 115 kind: 'Document', 37 116 definitions: [
+2 -7
packages/example/src/Pokemon.ts
··· 1 1 import { gql } from '@urql/core'; 2 2 3 - export const fields = gql` 4 - fragment fields on Pokemon { 5 - classification 6 - id 7 - } 8 - ` as typeof import('./Pokemon.generated').FieldsFragmentDoc; 9 - 10 3 export const PokemonFields = gql` 11 4 fragment pokemonFields on Pokemon { 12 5 id 13 6 name 14 7 } 15 8 ` as typeof import('./Pokemon.generated').PokemonFieldsFragmentDoc; 9 + 10 + export const Pokemon = () => 'hi';
-65
packages/example/src/index.generated.ts
··· 12 12 } | null> | null; 13 13 }; 14 14 15 - export type PokemonFieldsFragment = { 16 - __typename?: 'Pokemon'; 17 - id: string; 18 - name: string; 19 - }; 20 - 21 15 export type PokemonQueryVariables = Types.Exact<{ 22 16 id: Types.Scalars['ID']; 23 17 }>; ··· 28 22 __typename: 'Pokemon'; 29 23 id: string; 30 24 fleeRate?: number | null; 31 - name: string; 32 25 } | null; 33 26 }; 34 27 35 - export const PokemonFieldsFragmentDoc = { 36 - kind: 'Document', 37 - definitions: [ 38 - { 39 - kind: 'FragmentDefinition', 40 - name: { kind: 'Name', value: 'pokemonFields' }, 41 - typeCondition: { 42 - kind: 'NamedType', 43 - name: { kind: 'Name', value: 'Pokemon' }, 44 - }, 45 - selectionSet: { 46 - kind: 'SelectionSet', 47 - selections: [ 48 - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 49 - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 50 - ], 51 - }, 52 - }, 53 - ], 54 - } as unknown as DocumentNode<PokemonFieldsFragment, unknown>; 55 28 export const PokemonsDocument = { 56 29 kind: 'Document', 57 30 definitions: [ ··· 72 45 { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 73 46 { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, 74 47 { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, 75 - { 76 - kind: 'FragmentSpread', 77 - name: { kind: 'Name', value: 'pokemonFields' }, 78 - }, 79 48 ], 80 49 }, 81 50 }, 82 51 ], 83 52 }, 84 53 }, 85 - { 86 - kind: 'FragmentDefinition', 87 - name: { kind: 'Name', value: 'pokemonFields' }, 88 - typeCondition: { 89 - kind: 'NamedType', 90 - name: { kind: 'Name', value: 'Pokemon' }, 91 - }, 92 - selectionSet: { 93 - kind: 'SelectionSet', 94 - selections: [ 95 - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 96 - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 97 - ], 98 - }, 99 - }, 100 54 ], 101 55 } as unknown as DocumentNode<PokemonsQuery, PokemonsQueryVariables>; 102 56 export const PokemonDocument = { ··· 137 91 selections: [ 138 92 { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 139 93 { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, 140 - { 141 - kind: 'FragmentSpread', 142 - name: { kind: 'Name', value: 'pokemonFields' }, 143 - }, 144 94 { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, 145 95 ], 146 96 }, 147 97 }, 148 - ], 149 - }, 150 - }, 151 - { 152 - kind: 'FragmentDefinition', 153 - name: { kind: 'Name', value: 'pokemonFields' }, 154 - typeCondition: { 155 - kind: 'NamedType', 156 - name: { kind: 'Name', value: 'Pokemon' }, 157 - }, 158 - selectionSet: { 159 - kind: 'SelectionSet', 160 - selections: [ 161 - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, 162 - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 163 98 ], 164 99 }, 165 100 },
+1 -7
packages/example/src/index.ts
··· 1 1 import { gql, createClient } from '@urql/core'; 2 - import { PokemonFields } from './Pokemon'; 2 + import { Pokemon, PokemonFields } from './Pokemon'; 3 3 4 4 const PokemonsQuery = gql` 5 5 query Pokemons { ··· 8 8 name 9 9 __typename 10 10 fleeRate 11 - ...pokemonFields 12 11 } 13 12 } 14 - 15 - ${PokemonFields} 16 13 ` as typeof import('./index.generated').PokemonsDocument; 17 14 18 15 const client = createClient({ ··· 31 28 pokemon(id: $id) { 32 29 id 33 30 fleeRate 34 - ...pokemonFields 35 31 __typename 36 32 } 37 33 } 38 - 39 - ${PokemonFields} 40 34 ` as typeof import('./index.generated').PokemonDocument; 41 35 42 36 client
+8
packages/graphqlsp/README.md
··· 10 10 - Diagnostics for adding fields that don't exist, are deprecated, missmatched argument types, ... 11 11 - Auto-complete inside your editor for fields 12 12 - When you save it will generate `typed-document-nodes` for your documents and cast them to the correct type 13 + - Will warn you when you are importing from a file that is exporting fragments that you're not using 13 14 14 15 ## Installation 15 16 ··· 35 36 ``` 36 37 37 38 now restart your TS-server and you should be good to go 39 + 40 + ### Configuration 41 + 42 + - `schema` allows you to specify a url, `.json` or `.graphql` file as your schema 43 + - `scalars` allows you to pass an object of scalars that we'll feed into `graphql-code-generator` 44 + - `shouldCheckForColocatedFragments` when turned on (default), this will scan your imports to find 45 + unused fragments and provide a message notifying you about them 38 46 39 47 ## Local development 40 48
+45
packages/graphqlsp/src/ast.ts
··· 1 + import ts from 'typescript/lib/tsserverlibrary'; 2 + import { 3 + isImportDeclaration, 4 + isNoSubstitutionTemplateLiteral, 5 + isTaggedTemplateExpression, 6 + } from 'typescript'; 7 + 8 + export function findNode( 9 + sourceFile: ts.SourceFile, 10 + position: number 11 + ): ts.Node | undefined { 12 + function find(node: ts.Node): ts.Node | undefined { 13 + if (position >= node.getStart() && position < node.getEnd()) { 14 + return ts.forEachChild(node, find) || node; 15 + } 16 + } 17 + return find(sourceFile); 18 + } 19 + 20 + export function findAllTaggedTemplateNodes( 21 + sourceFile: ts.SourceFile | ts.Node 22 + ): Array<ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral> { 23 + const result: Array< 24 + ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral 25 + > = []; 26 + function find(node: ts.Node) { 27 + if ( 28 + isTaggedTemplateExpression(node) || 29 + isNoSubstitutionTemplateLiteral(node) 30 + ) { 31 + result.push(node); 32 + return; 33 + } else { 34 + ts.forEachChild(node, find); 35 + } 36 + } 37 + find(sourceFile); 38 + return result; 39 + } 40 + 41 + export function findAllImports( 42 + sourceFile: ts.SourceFile 43 + ): Array<ts.ImportDeclaration> { 44 + return sourceFile.statements.filter(isImportDeclaration); 45 + }
+110 -9
packages/graphqlsp/src/index.ts
··· 8 8 isTemplateExpression, 9 9 isImportTypeNode, 10 10 ImportTypeNode, 11 - CompletionEntry, 11 + isNamespaceImport, 12 + isNamedImportBindings, 12 13 } from 'typescript'; 13 14 import { 14 15 getHoverInformation, ··· 25 26 OperationDefinitionNode, 26 27 } from 'graphql'; 27 28 29 + import { findAllImports, findAllTaggedTemplateNodes, findNode } from './ast'; 28 30 import { Cursor } from './cursor'; 29 31 import { loadSchema } from './getSchema'; 30 32 import { getToken } from './token'; 31 33 import { 32 - findAllTaggedTemplateNodes, 33 - findNode, 34 34 getSource, 35 35 getSuggestionsForFragmentSpread, 36 36 isFileDirty, ··· 65 65 66 66 const tagTemplate = info.config.template || 'gql'; 67 67 const scalars = info.config.scalars || {}; 68 + const shouldCheckForColocatedFragments = 69 + info.config.shouldCheckForColocatedFragments || true; 68 70 69 71 const proxy = createBasicDecorator(info); 70 72 ··· 179 181 return result; 180 182 }); 181 183 182 - if (!newDiagnostics.length) { 184 + const imports = findAllImports(source); 185 + if (imports.length && shouldCheckForColocatedFragments) { 186 + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); 187 + imports.forEach(imp => { 188 + if (!imp.importClause) return; 189 + 190 + const importedNames: string[] = []; 191 + if (imp.importClause.name) { 192 + importedNames.push(imp.importClause?.name.text); 193 + } 194 + 195 + if ( 196 + imp.importClause.namedBindings && 197 + isNamespaceImport(imp.importClause.namedBindings) 198 + ) { 199 + // TODO: we might need to warn here when the fragment is unused as a namespace import 200 + return; 201 + } else if ( 202 + imp.importClause.namedBindings && 203 + isNamedImportBindings(imp.importClause.namedBindings) 204 + ) { 205 + imp.importClause.namedBindings.elements.forEach(el => { 206 + importedNames.push(el.name.text); 207 + }); 208 + } 209 + 210 + const symbol = typeChecker?.getSymbolAtLocation(imp.moduleSpecifier); 211 + if (!symbol) return; 212 + 213 + const moduleExports = typeChecker?.getExportsOfModule(symbol); 214 + if (!moduleExports) return; 215 + 216 + const missingImports = moduleExports 217 + .map(exp => { 218 + if (importedNames.includes(exp.name)) { 219 + return; 220 + } 221 + 222 + const declarations = exp.getDeclarations(); 223 + const declaration = declarations?.find(x => { 224 + // TODO: check whether the sourceFile.fileName resembles the module 225 + // specifier 226 + return true; 227 + }); 228 + 229 + if (!declaration) return; 230 + 231 + const [template] = findAllTaggedTemplateNodes(declaration); 232 + if (template) { 233 + let node = template; 234 + if ( 235 + isNoSubstitutionTemplateLiteral(node) || 236 + isTemplateExpression(node) 237 + ) { 238 + if (isTaggedTemplateExpression(node.parent)) { 239 + node = node.parent; 240 + } else { 241 + return; 242 + } 243 + } 244 + 245 + const text = resolveTemplate( 246 + node, 247 + node.getSourceFile().fileName, 248 + info 249 + ); 250 + const parsed = parse(text); 251 + if ( 252 + parsed.definitions.every( 253 + x => x.kind === Kind.FRAGMENT_DEFINITION 254 + ) 255 + ) { 256 + return `'${exp.name}'`; 257 + } 258 + } 259 + }) 260 + .filter(Boolean); 261 + 262 + if (missingImports.length) { 263 + // TODO: we could use getCodeFixesAtPosition 264 + // to build on this 265 + newDiagnostics.push({ 266 + file: source, 267 + length: imp.getText().length, 268 + start: imp.getStart(), 269 + category: ts.DiagnosticCategory.Message, 270 + code: 51001, 271 + messageText: `Missing Fragment import(s) ${missingImports.join( 272 + ', ' 273 + )} from ${imp.moduleSpecifier.getText()}.`, 274 + }); 275 + } 276 + }); 277 + } 278 + 279 + if ( 280 + !newDiagnostics.filter( 281 + x => 282 + x.category === ts.DiagnosticCategory.Error || 283 + x.category === ts.DiagnosticCategory.Warning 284 + ).length 285 + ) { 183 286 try { 184 287 const parts = source.fileName.split('/'); 185 288 const name = parts[parts.length - 1]; ··· 246 349 if (typeImport) { 247 350 // We only want the oldExportName here to be present 248 351 // that way we can diff its length vs the new one 249 - const oldExportName = typeImport 250 - .getText() 251 - .split('.') 252 - .pop() 352 + const oldExportName = typeImport.getText().split('.').pop(); 253 353 254 354 // Remove ` as ` from the beginning, 255 355 // this because getText() gives us everything ··· 257 357 // around. 258 358 imp = imp.slice(4); 259 359 text = source.text.replace(typeImport.getText(), imp); 260 - span.length = imp.length + ((oldExportName || '').length - exportName.length); 360 + span.length = 361 + imp.length + ((oldExportName || '').length - exportName.length); 261 362 } else { 262 363 text = 263 364 source.text.substring(0, span.start) +
+2 -1
packages/graphqlsp/src/resolve.ts
··· 6 6 TaggedTemplateExpression, 7 7 } from 'typescript'; 8 8 import ts from 'typescript/lib/tsserverlibrary'; 9 - import { findNode, getSource } from './utils'; 9 + import { findNode } from './ast'; 10 + import { getSource } from './utils'; 10 11 11 12 export function resolveTemplate( 12 13 node: TaggedTemplateExpression,
-37
packages/graphqlsp/src/utils.ts
··· 1 1 import ts from 'typescript/lib/tsserverlibrary'; 2 - import { 3 - isNoSubstitutionTemplateLiteral, 4 - isTaggedTemplateExpression, 5 - } from 'typescript'; 6 2 import fs from 'fs'; 7 3 import { 8 4 CompletionItem, ··· 34 30 const currentText = source.getFullText(); 35 31 36 32 return currentText !== contents; 37 - } 38 - 39 - export function findNode( 40 - sourceFile: ts.SourceFile, 41 - position: number 42 - ): ts.Node | undefined { 43 - function find(node: ts.Node): ts.Node | undefined { 44 - if (position >= node.getStart() && position < node.getEnd()) { 45 - return ts.forEachChild(node, find) || node; 46 - } 47 - } 48 - return find(sourceFile); 49 - } 50 - 51 - export function findAllTaggedTemplateNodes( 52 - sourceFile: ts.SourceFile 53 - ): Array<ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral> { 54 - const result: Array< 55 - ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral 56 - > = []; 57 - function find(node: ts.Node) { 58 - if ( 59 - isTaggedTemplateExpression(node) || 60 - isNoSubstitutionTemplateLiteral(node) 61 - ) { 62 - result.push(node); 63 - return; 64 - } else { 65 - ts.forEachChild(node, find); 66 - } 67 - } 68 - find(sourceFile); 69 - return result; 70 33 } 71 34 72 35 export function getSource(info: ts.server.PluginCreateInfo, filename: string) {