Skip to content

Commit

Permalink
fix(field-usage): Crawl chained scopes (#356)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoviDeCroock authored Sep 28, 2024
1 parent acede9b commit 563714c
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 55 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-queens-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@0no-co/graphqlsp': patch
---

Handle chained expressions while crawling scopes
159 changes: 104 additions & 55 deletions packages/graphqlsp/src/fieldUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string>,
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions test/e2e/fixture-project-tada/introspection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ export type introspection = {
};

import * as gqlTada from 'gql.tada';

declare module 'gql.tada' {
interface setupSchema {
introspection: introspection;
}
}
37 changes: 37 additions & 0 deletions test/e2e/fixture-project-unused-fields/fixtures/chained-usage.ts
Original file line number Diff line number Diff line change
@@ -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;
};
8 changes: 8 additions & 0 deletions test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

/**
Expand Down Expand Up @@ -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] ?? {};
Expand Down
41 changes: 41 additions & 0 deletions test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -338,3 +351,31 @@ export const PoDocument = {
},
],
} as unknown as DocumentNode<PoQuery, PoQueryVariables>;
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<PokQuery, PokQueryVariables>;
40 changes: 40 additions & 0 deletions test/e2e/unused-fieds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(() => {
Expand All @@ -138,6 +155,7 @@ describe('unused fields', () => {
fs.unlinkSync(outfileFragmentDestructuring);
fs.unlinkSync(outfileDestructuringFromStart);
fs.unlinkSync(outfileBail);
fs.unlinkSync(outfileChainedUsage);
} catch {}
});

Expand Down Expand Up @@ -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);
});

0 comments on commit 563714c

Please sign in to comment.