Mirror: TypeScript LSP plugin that finds GraphQL documents in your code and provides diagnostics, auto-complete and hover-information.

fix(field-usage): Crawl chained scopes (#356)

Changed files
+241 -55
.changeset
packages
graphqlsp
test
e2e
fixture-project-tada
fixture-project-unused-fields
+5
.changeset/beige-queens-worry.md
···
+
---
+
'@0no-co/graphqlsp': patch
+
---
+
+
Handle chained expressions while crawling scopes
+104 -55
packages/graphqlsp/src/fieldUsage.ts
···
import { parse, visit } from 'graphql';
import { findNode } from './ast';
+
import { PropertyAccessExpression } from 'typescript';
export const UNUSED_FIELD_CODE = 52005;
···
'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>,
···
// - 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) ||
···
arrayMethods.has(foundRef.name.text) &&
ts.isCallExpression(foundRef.parent)
) {
-
const isReduce = foundRef.name.text === 'reduce';
+
const callExpression = foundRef.parent;
+
const res = [];
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();
+
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);
+
}
-
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 (ts.isVariableDeclaration(callExpression.parent) && !isSomeOrEvery) {
+
const varRes = crawlScope(
+
callExpression.parent.name,
+
pathParts,
+
allFields,
+
source,
+
info,
+
true
+
);
+
res.push(...varRes);
}
-
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;
-
}
-
}
+
return res;
} else if (
ts.isPropertyAccessExpression(foundRef) &&
!pathParts.includes(foundRef.name.text)
+6
test/e2e/fixture-project-tada/introspection.d.ts
···
};
import * as gqlTada from 'gql.tada';
+
+
declare module 'gql.tada' {
+
interface setupSchema {
+
introspection: introspection;
+
}
+
}
+37
test/e2e/fixture-project-unused-fields/fixtures/chained-usage.ts
···
+
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
test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts
···
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,
};
/**
···
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] ?? {};
+41
test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts
···
| 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: [
···
},
],
} 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
test/e2e/unused-fieds.test.ts
···
);
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 () => {
···
} satisfies ts.server.protocol.OpenRequestArgs);
server.sendCommand('open', {
file: outfileDestructuringFromStart,
+
fileContent: '// empty',
+
scriptKindName: 'TS',
+
} satisfies ts.server.protocol.OpenRequestArgs);
+
server.sendCommand('open', {
+
file: outfileChainedUsage,
fileContent: '// empty',
scriptKindName: 'TS',
} satisfies ts.server.protocol.OpenRequestArgs);
···
'utf-8'
),
},
+
{
+
file: outfileChainedUsage,
+
fileContent: fs.readFileSync(
+
path.join(projectPath, 'fixtures/chained-usage.ts'),
+
'utf-8'
+
),
+
},
],
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
···
file: outfileBail,
tmpfile: outfileBail,
} satisfies ts.server.protocol.SavetoRequestArgs);
+
server.sendCommand('saveto', {
+
file: outfileChainedUsage,
+
tmpfile: outfileChainedUsage,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
});
afterAll(() => {
···
fs.unlinkSync(outfileFragmentDestructuring);
fs.unlinkSync(outfileDestructuringFromStart);
fs.unlinkSync(outfileBail);
+
fs.unlinkSync(outfileChainedUsage);
} catch {}
});
···
},
]
`);
+
}, 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);
});