From 9279662bc74b58ba5bd58fe2d7894e4ae14f29d5 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/graphql.ts | 41 +++++ test/e2e/unused-fieds.test.ts | 44 +++++ 6 files changed, 237 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/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..f209fe23 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,30 @@ 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).toMatchInlineSnapshot(` + [ + { + "category": "error", + "code": 2578, + "end": { + "line": 4, + "offset": 20, + }, + "start": { + "line": 4, + "offset": 1, + }, + "text": "Unused '@ts-expect-error' directive.", + }, + ] + `); + }, 30000); });