From c5233881f95485d7050c2b0328fc4aa17a556b8e Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Sat, 14 Sep 2024 17:20:37 +0200 Subject: [PATCH] Crawl nested scopes --- .changeset/beige-queens-worry.md | 5 + packages/graphqlsp/src/fieldUsage.ts | 159 ++++++++++++------ .../fixture-project-tada/introspection.d.ts | 6 + .../fixtures/chained-usage.ts | 37 ++++ .../fixtures/gql/gql.ts | 8 + .../fixtures/gql/graphql.ts | 41 +++++ test/e2e/unused-fieds.test.ts | 40 +++++ 7 files changed, 241 insertions(+), 55 deletions(-) create mode 100644 .changeset/beige-queens-worry.md create mode 100644 test/e2e/fixture-project-unused-fields/fixtures/chained-usage.ts diff --git a/.changeset/beige-queens-worry.md b/.changeset/beige-queens-worry.md new file mode 100644 index 00000000..4a0a48d5 --- /dev/null +++ b/.changeset/beige-queens-worry.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': patch +--- + +Handle chained expressions while crawling scopes diff --git a/packages/graphqlsp/src/fieldUsage.ts b/packages/graphqlsp/src/fieldUsage.ts index 27b32a3f..8f24fc13 100644 --- a/packages/graphqlsp/src/fieldUsage.ts +++ b/packages/graphqlsp/src/fieldUsage.ts @@ -2,6 +2,7 @@ import { ts } from './ts'; import { parse, visit } from 'graphql'; import { findNode } from './ast'; +import { PropertyAccessExpression } from 'typescript'; export const UNUSED_FIELD_CODE = 52005; @@ -119,6 +120,82 @@ const arrayMethods = new Set([ 'sort', ]); +const crawlChainedExpressions = ( + ref: ts.CallExpression, + pathParts: string[], + allFields: string[], + source: ts.SourceFile, + info: ts.server.PluginCreateInfo +): string[] => { + const isChained = + ts.isPropertyAccessExpression(ref.expression) && + arrayMethods.has(ref.expression.name.text); + console.log('[GRAPHQLSP]: ', isChained, ref.getFullText()); + if (isChained) { + const foundRef = ref.expression; + const isReduce = foundRef.name.text === 'reduce'; + let func: ts.Expression | ts.FunctionDeclaration | undefined = + ref.arguments[0]; + + const res = []; + if (ts.isCallExpression(ref.parent.parent)) { + const nestedResult = crawlChainedExpressions( + ref.parent.parent, + pathParts, + allFields, + source, + info + ); + if (nestedResult.length) { + res.push(...nestedResult); + } + } + + if (func && ts.isIdentifier(func)) { + // TODO: Scope utilities in checkFieldUsageInFile to deduplicate + const checker = info.languageService.getProgram()!.getTypeChecker(); + + const declaration = checker.getSymbolAtLocation(func)?.valueDeclaration; + if (declaration && ts.isFunctionDeclaration(declaration)) { + func = declaration; + } else if ( + declaration && + ts.isVariableDeclaration(declaration) && + declaration.initializer + ) { + func = declaration.initializer; + } + } + + if ( + func && + (ts.isFunctionDeclaration(func) || + ts.isFunctionExpression(func) || + ts.isArrowFunction(func)) + ) { + const param = func.parameters[isReduce ? 1 : 0]; + if (param) { + const scopedResult = crawlScope( + param.name, + pathParts, + allFields, + source, + info, + true + ); + + if (scopedResult.length) { + res.push(...scopedResult); + } + } + } + + return res; + } + + return []; +}; + const crawlScope = ( node: ts.BindingName, originalWip: Array, @@ -173,6 +250,7 @@ const crawlScope = ( // - const pokemon = result.data.pokemon --> this initiates a new crawl with a renewed scope // - const { pokemon } = result.data --> this initiates a destructuring traversal which will // either end up in more destructuring traversals or a scope crawl + console.log('[GRAPHQLSP]: ', foundRef.getFullText()); while ( ts.isIdentifier(foundRef) || ts.isPropertyAccessExpression(foundRef) || @@ -219,65 +297,36 @@ const crawlScope = ( arrayMethods.has(foundRef.name.text) && ts.isCallExpression(foundRef.parent) ) { - const isReduce = foundRef.name.text === 'reduce'; - const isSomeOrEvery = - foundRef.name.text === 'every' || foundRef.name.text === 'some'; const callExpression = foundRef.parent; - let func: ts.Expression | ts.FunctionDeclaration | undefined = - callExpression.arguments[0]; - - if (func && ts.isIdentifier(func)) { - // TODO: Scope utilities in checkFieldUsageInFile to deduplicate - const checker = info.languageService.getProgram()!.getTypeChecker(); - - const declaration = - checker.getSymbolAtLocation(func)?.valueDeclaration; - if (declaration && ts.isFunctionDeclaration(declaration)) { - func = declaration; - } else if ( - declaration && - ts.isVariableDeclaration(declaration) && - declaration.initializer - ) { - func = declaration.initializer; - } + const res = []; + const isSomeOrEvery = + foundRef.name.text === 'some' || foundRef.name.text === 'every'; + console.log('[GRAPHQLSP]: ', foundRef.name.text); + const chainedResults = crawlChainedExpressions( + callExpression, + pathParts, + allFields, + source, + info + ); + console.log('[GRAPHQLSP]: ', chainedResults.length); + if (chainedResults.length) { + res.push(...chainedResults); } - if ( - func && - (ts.isFunctionDeclaration(func) || - ts.isFunctionExpression(func) || - ts.isArrowFunction(func)) - ) { - const param = func.parameters[isReduce ? 1 : 0]; - if (param) { - const res = crawlScope( - param.name, - pathParts, - allFields, - source, - info, - true - ); - - if ( - ts.isVariableDeclaration(callExpression.parent) && - !isSomeOrEvery - ) { - const varRes = crawlScope( - callExpression.parent.name, - pathParts, - allFields, - source, - info, - true - ); - res.push(...varRes); - } - - return res; - } + if (ts.isVariableDeclaration(callExpression.parent) && !isSomeOrEvery) { + const varRes = crawlScope( + callExpression.parent.name, + pathParts, + allFields, + source, + info, + true + ); + res.push(...varRes); } + + return res; } else if ( ts.isPropertyAccessExpression(foundRef) && !pathParts.includes(foundRef.name.text) diff --git a/test/e2e/fixture-project-tada/introspection.d.ts b/test/e2e/fixture-project-tada/introspection.d.ts index 22ac94ee..4095b266 100644 --- a/test/e2e/fixture-project-tada/introspection.d.ts +++ b/test/e2e/fixture-project-tada/introspection.d.ts @@ -31,3 +31,9 @@ export type introspection = { }; import * as gqlTada from 'gql.tada'; + +declare module 'gql.tada' { + interface setupSchema { + introspection: introspection; + } +} diff --git a/test/e2e/fixture-project-unused-fields/fixtures/chained-usage.ts b/test/e2e/fixture-project-unused-fields/fixtures/chained-usage.ts new file mode 100644 index 00000000..70794257 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/chained-usage.ts @@ -0,0 +1,37 @@ +import { useQuery } from 'urql'; +import { useMemo } from 'react'; +import { graphql } from './gql'; + +const PokemonsQuery = graphql( + ` + query Pok { + pokemons { + name + maxCP + maxHP + fleeRate + } + } + ` +); + +const Pokemons = () => { + const [result] = useQuery({ + query: PokemonsQuery, + }); + + const results = useMemo(() => { + if (!result.data?.pokemons) return []; + return ( + result.data.pokemons + .filter(i => i?.name === 'Pikachu') + .map(p => ({ + x: p?.maxCP, + y: p?.maxHP, + })) ?? [] + ); + }, [result.data?.pokemons]); + + // @ts-ignore + return results; +}; diff --git a/test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts b/test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts index edbe1b17..d1622cfd 100644 --- a/test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts +++ b/test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts @@ -17,6 +17,8 @@ const documents = { types.PokemonFieldsFragmentDoc, '\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': types.PoDocument, + '\n query Pok {\n pokemons {\n name\n maxCP\n maxHP\n fleeRate\n }\n }\n ': + types.PokDocument, }; /** @@ -45,6 +47,12 @@ export function graphql( export function graphql( 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' ): (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']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query Pok {\n pokemons {\n name\n maxCP\n maxHP\n fleeRate\n }\n }\n ' +): (typeof documents)['\n query Pok {\n pokemons {\n name\n maxCP\n maxHP\n fleeRate\n }\n }\n ']; export function graphql(source: string) { return (documents as any)[source] ?? {}; diff --git a/test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts b/test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts index 6d5427bf..dce3844b 100644 --- a/test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts +++ b/test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts @@ -162,6 +162,19 @@ export type PoQuery = { | null; }; +export type PokQueryVariables = Exact<{ [key: string]: never }>; + +export type PokQuery = { + __typename?: 'Query'; + pokemons?: Array<{ + __typename?: 'Pokemon'; + name: string; + maxCP?: number | null; + maxHP?: number | null; + fleeRate?: number | null; + } | null> | null; +}; + export const PokemonFieldsFragmentDoc = { kind: 'Document', definitions: [ @@ -338,3 +351,31 @@ export const PoDocument = { }, ], } as unknown as DocumentNode; +export const PokDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Pok' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'pokemons' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'maxCP' } }, + { kind: 'Field', name: { kind: 'Name', value: 'maxHP' } }, + { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; diff --git a/test/e2e/unused-fieds.test.ts b/test/e2e/unused-fieds.test.ts index 0e222cfe..695179a5 100644 --- a/test/e2e/unused-fieds.test.ts +++ b/test/e2e/unused-fieds.test.ts @@ -21,6 +21,7 @@ describe('unused fields', () => { ); const outfileFragment = path.join(projectPath, 'fragment.tsx'); const outfilePropAccess = path.join(projectPath, 'property-access.tsx'); + const outfileChainedUsage = path.join(projectPath, 'chained-usage.ts'); let server: TSServer; beforeAll(async () => { @@ -56,6 +57,11 @@ describe('unused fields', () => { fileContent: '// empty', scriptKindName: 'TS', } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfileChainedUsage, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); server.sendCommand('updateOpen', { openFiles: [ @@ -101,6 +107,13 @@ describe('unused fields', () => { 'utf-8' ), }, + { + file: outfileChainedUsage, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/chained-usage.ts'), + 'utf-8' + ), + }, ], } satisfies ts.server.protocol.UpdateOpenRequestArgs); @@ -128,6 +141,10 @@ describe('unused fields', () => { file: outfileBail, tmpfile: outfileBail, } satisfies ts.server.protocol.SavetoRequestArgs); + server.sendCommand('saveto', { + file: outfileChainedUsage, + tmpfile: outfileChainedUsage, + } satisfies ts.server.protocol.SavetoRequestArgs); }); afterAll(() => { @@ -138,6 +155,7 @@ describe('unused fields', () => { fs.unlinkSync(outfileFragmentDestructuring); fs.unlinkSync(outfileDestructuringFromStart); fs.unlinkSync(outfileBail); + fs.unlinkSync(outfileChainedUsage); } catch {} }); @@ -405,4 +423,26 @@ describe('unused fields', () => { ] `); }, 30000); + + it('Finds field usage in chained call-expressions', async () => { + const res = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfileChainedUsage + ); + expect(res[0].body.diagnostics[0]).toEqual({ + category: 'warning', + code: 52005, + end: { + line: 8, + offset: 15, + }, + start: { + line: 8, + offset: 7, + }, + text: "Field(s) 'pokemons.fleeRate' are not used.", + }); + }, 30000); });