Skip to content

Commit 563714c

Browse files
authored
fix(field-usage): Crawl chained scopes (#356)
1 parent acede9b commit 563714c

File tree

7 files changed

+241
-55
lines changed

7 files changed

+241
-55
lines changed

.changeset/beige-queens-worry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@0no-co/graphqlsp': patch
3+
---
4+
5+
Handle chained expressions while crawling scopes

packages/graphqlsp/src/fieldUsage.ts

Lines changed: 104 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ts } from './ts';
22
import { parse, visit } from 'graphql';
33

44
import { findNode } from './ast';
5+
import { PropertyAccessExpression } from 'typescript';
56

67
export const UNUSED_FIELD_CODE = 52005;
78

@@ -119,6 +120,82 @@ const arrayMethods = new Set([
119120
'sort',
120121
]);
121122

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+
122199
const crawlScope = (
123200
node: ts.BindingName,
124201
originalWip: Array<string>,
@@ -173,6 +250,7 @@ const crawlScope = (
173250
// - const pokemon = result.data.pokemon --> this initiates a new crawl with a renewed scope
174251
// - const { pokemon } = result.data --> this initiates a destructuring traversal which will
175252
// either end up in more destructuring traversals or a scope crawl
253+
console.log('[GRAPHQLSP]: ', foundRef.getFullText());
176254
while (
177255
ts.isIdentifier(foundRef) ||
178256
ts.isPropertyAccessExpression(foundRef) ||
@@ -219,65 +297,36 @@ const crawlScope = (
219297
arrayMethods.has(foundRef.name.text) &&
220298
ts.isCallExpression(foundRef.parent)
221299
) {
222-
const isReduce = foundRef.name.text === 'reduce';
223-
const isSomeOrEvery =
224-
foundRef.name.text === 'every' || foundRef.name.text === 'some';
225300
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();
232-
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-
}
301+
const res = [];
302+
const isSomeOrEvery =
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);
244315
}
245316

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-
}
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);
280327
}
328+
329+
return res;
281330
} else if (
282331
ts.isPropertyAccessExpression(foundRef) &&
283332
!pathParts.includes(foundRef.name.text)

test/e2e/fixture-project-tada/introspection.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ export type introspection = {
3131
};
3232

3333
import * as gqlTada from 'gql.tada';
34+
35+
declare module 'gql.tada' {
36+
interface setupSchema {
37+
introspection: introspection;
38+
}
39+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
};

test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const documents = {
1717
types.PokemonFieldsFragmentDoc,
1818
'\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':
1919
types.PoDocument,
20+
'\n query Pok {\n pokemons {\n name\n maxCP\n maxHP\n fleeRate\n }\n }\n ':
21+
types.PokDocument,
2022
};
2123

2224
/**
@@ -45,6 +47,12 @@ export function graphql(
4547
export function graphql(
4648
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'
4749
): (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 '];
4856

4957
export function graphql(source: string) {
5058
return (documents as any)[source] ?? {};

test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,19 @@ export type PoQuery = {
162162
| null;
163163
};
164164

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+
165178
export const PokemonFieldsFragmentDoc = {
166179
kind: 'Document',
167180
definitions: [
@@ -338,3 +351,31 @@ export const PoDocument = {
338351
},
339352
],
340353
} 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>;

test/e2e/unused-fieds.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe('unused fields', () => {
2121
);
2222
const outfileFragment = path.join(projectPath, 'fragment.tsx');
2323
const outfilePropAccess = path.join(projectPath, 'property-access.tsx');
24+
const outfileChainedUsage = path.join(projectPath, 'chained-usage.ts');
2425

2526
let server: TSServer;
2627
beforeAll(async () => {
@@ -56,6 +57,11 @@ describe('unused fields', () => {
5657
fileContent: '// empty',
5758
scriptKindName: 'TS',
5859
} satisfies ts.server.protocol.OpenRequestArgs);
60+
server.sendCommand('open', {
61+
file: outfileChainedUsage,
62+
fileContent: '// empty',
63+
scriptKindName: 'TS',
64+
} satisfies ts.server.protocol.OpenRequestArgs);
5965

6066
server.sendCommand('updateOpen', {
6167
openFiles: [
@@ -101,6 +107,13 @@ describe('unused fields', () => {
101107
'utf-8'
102108
),
103109
},
110+
{
111+
file: outfileChainedUsage,
112+
fileContent: fs.readFileSync(
113+
path.join(projectPath, 'fixtures/chained-usage.ts'),
114+
'utf-8'
115+
),
116+
},
104117
],
105118
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
106119

@@ -128,6 +141,10 @@ describe('unused fields', () => {
128141
file: outfileBail,
129142
tmpfile: outfileBail,
130143
} satisfies ts.server.protocol.SavetoRequestArgs);
144+
server.sendCommand('saveto', {
145+
file: outfileChainedUsage,
146+
tmpfile: outfileChainedUsage,
147+
} satisfies ts.server.protocol.SavetoRequestArgs);
131148
});
132149

133150
afterAll(() => {
@@ -138,6 +155,7 @@ describe('unused fields', () => {
138155
fs.unlinkSync(outfileFragmentDestructuring);
139156
fs.unlinkSync(outfileDestructuringFromStart);
140157
fs.unlinkSync(outfileBail);
158+
fs.unlinkSync(outfileChainedUsage);
141159
} catch {}
142160
});
143161

@@ -405,4 +423,26 @@ describe('unused fields', () => {
405423
]
406424
`);
407425
}, 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+
});
447+
}, 30000);
408448
});

0 commit comments

Comments
 (0)