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(field-usage): Crawl chained scopes (#356)

authored by

Jovi De Croock and committed by
GitHub
563714ca acede9b0

+241 -55
+5
.changeset/beige-queens-worry.md
··· 1 + --- 2 + '@0no-co/graphqlsp': patch 3 + --- 4 + 5 + Handle chained expressions while crawling scopes
+104 -55
packages/graphqlsp/src/fieldUsage.ts
··· 2 2 import { parse, visit } from 'graphql'; 3 3 4 4 import { findNode } from './ast'; 5 + import { PropertyAccessExpression } from 'typescript'; 5 6 6 7 export const UNUSED_FIELD_CODE = 52005; 7 8 ··· 119 120 'sort', 120 121 ]); 121 122 123 + const crawlChainedExpressions = ( 124 + ref: ts.CallExpression, 125 + pathParts: string[], 126 + allFields: string[], 127 + source: ts.SourceFile, 128 + info: ts.server.PluginCreateInfo 129 + ): string[] => { 130 + const isChained = 131 + ts.isPropertyAccessExpression(ref.expression) && 132 + arrayMethods.has(ref.expression.name.text); 133 + console.log('[GRAPHQLSP]: ', isChained, ref.getFullText()); 134 + if (isChained) { 135 + const foundRef = ref.expression; 136 + const isReduce = foundRef.name.text === 'reduce'; 137 + let func: ts.Expression | ts.FunctionDeclaration | undefined = 138 + ref.arguments[0]; 139 + 140 + const res = []; 141 + if (ts.isCallExpression(ref.parent.parent)) { 142 + const nestedResult = crawlChainedExpressions( 143 + ref.parent.parent, 144 + pathParts, 145 + allFields, 146 + source, 147 + info 148 + ); 149 + if (nestedResult.length) { 150 + res.push(...nestedResult); 151 + } 152 + } 153 + 154 + if (func && ts.isIdentifier(func)) { 155 + // TODO: Scope utilities in checkFieldUsageInFile to deduplicate 156 + const checker = info.languageService.getProgram()!.getTypeChecker(); 157 + 158 + const declaration = checker.getSymbolAtLocation(func)?.valueDeclaration; 159 + if (declaration && ts.isFunctionDeclaration(declaration)) { 160 + func = declaration; 161 + } else if ( 162 + declaration && 163 + ts.isVariableDeclaration(declaration) && 164 + declaration.initializer 165 + ) { 166 + func = declaration.initializer; 167 + } 168 + } 169 + 170 + if ( 171 + func && 172 + (ts.isFunctionDeclaration(func) || 173 + ts.isFunctionExpression(func) || 174 + ts.isArrowFunction(func)) 175 + ) { 176 + const param = func.parameters[isReduce ? 1 : 0]; 177 + if (param) { 178 + const scopedResult = crawlScope( 179 + param.name, 180 + pathParts, 181 + allFields, 182 + source, 183 + info, 184 + true 185 + ); 186 + 187 + if (scopedResult.length) { 188 + res.push(...scopedResult); 189 + } 190 + } 191 + } 192 + 193 + return res; 194 + } 195 + 196 + return []; 197 + }; 198 + 122 199 const crawlScope = ( 123 200 node: ts.BindingName, 124 201 originalWip: Array<string>, ··· 173 250 // - const pokemon = result.data.pokemon --> this initiates a new crawl with a renewed scope 174 251 // - const { pokemon } = result.data --> this initiates a destructuring traversal which will 175 252 // either end up in more destructuring traversals or a scope crawl 253 + console.log('[GRAPHQLSP]: ', foundRef.getFullText()); 176 254 while ( 177 255 ts.isIdentifier(foundRef) || 178 256 ts.isPropertyAccessExpression(foundRef) || ··· 219 297 arrayMethods.has(foundRef.name.text) && 220 298 ts.isCallExpression(foundRef.parent) 221 299 ) { 222 - const isReduce = foundRef.name.text === 'reduce'; 300 + const callExpression = foundRef.parent; 301 + const res = []; 223 302 const isSomeOrEvery = 224 - foundRef.name.text === 'every' || foundRef.name.text === 'some'; 225 - const callExpression = foundRef.parent; 226 - let func: ts.Expression | ts.FunctionDeclaration | undefined = 227 - callExpression.arguments[0]; 228 - 229 - if (func && ts.isIdentifier(func)) { 230 - // TODO: Scope utilities in checkFieldUsageInFile to deduplicate 231 - const checker = info.languageService.getProgram()!.getTypeChecker(); 303 + foundRef.name.text === 'some' || foundRef.name.text === 'every'; 304 + console.log('[GRAPHQLSP]: ', foundRef.name.text); 305 + const chainedResults = crawlChainedExpressions( 306 + callExpression, 307 + pathParts, 308 + allFields, 309 + source, 310 + info 311 + ); 312 + console.log('[GRAPHQLSP]: ', chainedResults.length); 313 + if (chainedResults.length) { 314 + res.push(...chainedResults); 315 + } 232 316 233 - const declaration = 234 - checker.getSymbolAtLocation(func)?.valueDeclaration; 235 - if (declaration && ts.isFunctionDeclaration(declaration)) { 236 - func = declaration; 237 - } else if ( 238 - declaration && 239 - ts.isVariableDeclaration(declaration) && 240 - declaration.initializer 241 - ) { 242 - func = declaration.initializer; 243 - } 317 + if (ts.isVariableDeclaration(callExpression.parent) && !isSomeOrEvery) { 318 + const varRes = crawlScope( 319 + callExpression.parent.name, 320 + pathParts, 321 + allFields, 322 + source, 323 + info, 324 + true 325 + ); 326 + res.push(...varRes); 244 327 } 245 328 246 - if ( 247 - func && 248 - (ts.isFunctionDeclaration(func) || 249 - ts.isFunctionExpression(func) || 250 - ts.isArrowFunction(func)) 251 - ) { 252 - const param = func.parameters[isReduce ? 1 : 0]; 253 - if (param) { 254 - const res = crawlScope( 255 - param.name, 256 - pathParts, 257 - allFields, 258 - source, 259 - info, 260 - true 261 - ); 262 - 263 - if ( 264 - ts.isVariableDeclaration(callExpression.parent) && 265 - !isSomeOrEvery 266 - ) { 267 - const varRes = crawlScope( 268 - callExpression.parent.name, 269 - pathParts, 270 - allFields, 271 - source, 272 - info, 273 - true 274 - ); 275 - res.push(...varRes); 276 - } 277 - 278 - return res; 279 - } 280 - } 329 + return res; 281 330 } else if ( 282 331 ts.isPropertyAccessExpression(foundRef) && 283 332 !pathParts.includes(foundRef.name.text)
+6
test/e2e/fixture-project-tada/introspection.d.ts
··· 31 31 }; 32 32 33 33 import * as gqlTada from 'gql.tada'; 34 + 35 + declare module 'gql.tada' { 36 + interface setupSchema { 37 + introspection: introspection; 38 + } 39 + }
+37
test/e2e/fixture-project-unused-fields/fixtures/chained-usage.ts
··· 1 + import { useQuery } from 'urql'; 2 + import { useMemo } from 'react'; 3 + import { graphql } from './gql'; 4 + 5 + const PokemonsQuery = graphql( 6 + ` 7 + query Pok { 8 + pokemons { 9 + name 10 + maxCP 11 + maxHP 12 + fleeRate 13 + } 14 + } 15 + ` 16 + ); 17 + 18 + const Pokemons = () => { 19 + const [result] = useQuery({ 20 + query: PokemonsQuery, 21 + }); 22 + 23 + const results = useMemo(() => { 24 + if (!result.data?.pokemons) return []; 25 + return ( 26 + result.data.pokemons 27 + .filter(i => i?.name === 'Pikachu') 28 + .map(p => ({ 29 + x: p?.maxCP, 30 + y: p?.maxHP, 31 + })) ?? [] 32 + ); 33 + }, [result.data?.pokemons]); 34 + 35 + // @ts-ignore 36 + return results; 37 + };
+8
test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts
··· 17 17 types.PokemonFieldsFragmentDoc, 18 18 '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n': 19 19 types.PoDocument, 20 + '\n query Pok {\n pokemons {\n name\n maxCP\n maxHP\n fleeRate\n }\n }\n ': 21 + types.PokDocument, 20 22 }; 21 23 22 24 /** ··· 45 47 export function graphql( 46 48 source: '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n' 47 49 ): (typeof documents)['\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n']; 50 + /** 51 + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 52 + */ 53 + export function graphql( 54 + source: '\n query Pok {\n pokemons {\n name\n maxCP\n maxHP\n fleeRate\n }\n }\n ' 55 + ): (typeof documents)['\n query Pok {\n pokemons {\n name\n maxCP\n maxHP\n fleeRate\n }\n }\n ']; 48 56 49 57 export function graphql(source: string) { 50 58 return (documents as any)[source] ?? {};
+41
test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts
··· 162 162 | null; 163 163 }; 164 164 165 + export type PokQueryVariables = Exact<{ [key: string]: never }>; 166 + 167 + export type PokQuery = { 168 + __typename?: 'Query'; 169 + pokemons?: Array<{ 170 + __typename?: 'Pokemon'; 171 + name: string; 172 + maxCP?: number | null; 173 + maxHP?: number | null; 174 + fleeRate?: number | null; 175 + } | null> | null; 176 + }; 177 + 165 178 export const PokemonFieldsFragmentDoc = { 166 179 kind: 'Document', 167 180 definitions: [ ··· 338 351 }, 339 352 ], 340 353 } as unknown as DocumentNode<PoQuery, PoQueryVariables>; 354 + export const PokDocument = { 355 + kind: 'Document', 356 + definitions: [ 357 + { 358 + kind: 'OperationDefinition', 359 + operation: 'query', 360 + name: { kind: 'Name', value: 'Pok' }, 361 + selectionSet: { 362 + kind: 'SelectionSet', 363 + selections: [ 364 + { 365 + kind: 'Field', 366 + name: { kind: 'Name', value: 'pokemons' }, 367 + selectionSet: { 368 + kind: 'SelectionSet', 369 + selections: [ 370 + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, 371 + { kind: 'Field', name: { kind: 'Name', value: 'maxCP' } }, 372 + { kind: 'Field', name: { kind: 'Name', value: 'maxHP' } }, 373 + { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, 374 + ], 375 + }, 376 + }, 377 + ], 378 + }, 379 + }, 380 + ], 381 + } as unknown as DocumentNode<PokQuery, PokQueryVariables>;
+40
test/e2e/unused-fieds.test.ts
··· 21 21 ); 22 22 const outfileFragment = path.join(projectPath, 'fragment.tsx'); 23 23 const outfilePropAccess = path.join(projectPath, 'property-access.tsx'); 24 + const outfileChainedUsage = path.join(projectPath, 'chained-usage.ts'); 24 25 25 26 let server: TSServer; 26 27 beforeAll(async () => { ··· 53 54 } satisfies ts.server.protocol.OpenRequestArgs); 54 55 server.sendCommand('open', { 55 56 file: outfileDestructuringFromStart, 57 + fileContent: '// empty', 58 + scriptKindName: 'TS', 59 + } satisfies ts.server.protocol.OpenRequestArgs); 60 + server.sendCommand('open', { 61 + file: outfileChainedUsage, 56 62 fileContent: '// empty', 57 63 scriptKindName: 'TS', 58 64 } satisfies ts.server.protocol.OpenRequestArgs); ··· 101 107 'utf-8' 102 108 ), 103 109 }, 110 + { 111 + file: outfileChainedUsage, 112 + fileContent: fs.readFileSync( 113 + path.join(projectPath, 'fixtures/chained-usage.ts'), 114 + 'utf-8' 115 + ), 116 + }, 104 117 ], 105 118 } satisfies ts.server.protocol.UpdateOpenRequestArgs); 106 119 ··· 128 141 file: outfileBail, 129 142 tmpfile: outfileBail, 130 143 } satisfies ts.server.protocol.SavetoRequestArgs); 144 + server.sendCommand('saveto', { 145 + file: outfileChainedUsage, 146 + tmpfile: outfileChainedUsage, 147 + } satisfies ts.server.protocol.SavetoRequestArgs); 131 148 }); 132 149 133 150 afterAll(() => { ··· 138 155 fs.unlinkSync(outfileFragmentDestructuring); 139 156 fs.unlinkSync(outfileDestructuringFromStart); 140 157 fs.unlinkSync(outfileBail); 158 + fs.unlinkSync(outfileChainedUsage); 141 159 } catch {} 142 160 }); 143 161 ··· 404 422 }, 405 423 ] 406 424 `); 425 + }, 30000); 426 + 427 + it('Finds field usage in chained call-expressions', async () => { 428 + const res = server.responses.filter( 429 + resp => 430 + resp.type === 'event' && 431 + resp.event === 'semanticDiag' && 432 + resp.body?.file === outfileChainedUsage 433 + ); 434 + expect(res[0].body.diagnostics[0]).toEqual({ 435 + category: 'warning', 436 + code: 52005, 437 + end: { 438 + line: 8, 439 + offset: 15, 440 + }, 441 + start: { 442 + line: 8, 443 + offset: 7, 444 + }, 445 + text: "Field(s) 'pokemons.fleeRate' are not used.", 446 + }); 407 447 }, 30000); 408 448 });