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

feat: add missing fragment spread warnings (#152)

* warn for missing fragment spreads with client-preset

* add todo and bail logic

* add changeset

* optimise

* add test

* fixies

* fix suggestions

Changed files
+350 -138
.changeset
packages
example-external-generator
graphqlsp
test
e2e
fixture-project-client-preset
+5
.changeset/mean-news-unite.md
···
···
+
---
+
'@0no-co/graphqlsp': minor
+
---
+
+
Warn when an import defines a fragment that is unused in the current file
+1 -1
packages/example-external-generator/tsconfig.json
···
"name": "@0no-co/graphqlsp",
"schema": "./schema.graphql",
"disableTypegen": true,
-
"shouldCheckForColocatedFragments": false,
"template": "graphql",
"templateIsCallExpression": true,
"trackFieldUsage": true
···
"name": "@0no-co/graphqlsp",
"schema": "./schema.graphql",
"disableTypegen": true,
+
"shouldCheckForColocatedFragments": true,
"template": "graphql",
"templateIsCallExpression": true,
"trackFieldUsage": true
+3 -2
packages/graphqlsp/src/ast/index.ts
···
export function findAllCallExpressions(
sourceFile: ts.SourceFile,
template: string,
-
info: ts.server.PluginCreateInfo
): {
nodes: Array<ts.NoSubstitutionTemplateLiteral>;
fragments: Array<FragmentDefinitionNode>;
} {
const result: Array<ts.NoSubstitutionTemplateLiteral> = [];
let fragments: Array<FragmentDefinitionNode> = [];
-
let hasTriedToFindFragments = false;
function find(node: ts.Node) {
if (ts.isCallExpression(node) && node.expression.getText() === template) {
if (!hasTriedToFindFragments) {
···
export function findAllCallExpressions(
sourceFile: ts.SourceFile,
template: string,
+
info: ts.server.PluginCreateInfo,
+
shouldSearchFragments: boolean = true
): {
nodes: Array<ts.NoSubstitutionTemplateLiteral>;
fragments: Array<FragmentDefinitionNode>;
} {
const result: Array<ts.NoSubstitutionTemplateLiteral> = [];
let fragments: Array<FragmentDefinitionNode> = [];
+
let hasTriedToFindFragments = shouldSearchFragments ? false : true;
function find(node: ts.Node) {
if (ts.isCallExpression(node) && node.expression.getText() === template) {
if (!hasTriedToFindFragments) {
+7 -3
packages/graphqlsp/src/autoComplete.ts
···
const foundToken = getToken(node.arguments[0], cursorPosition);
if (!schema.current || !foundToken) return undefined;
-
const queryText = node.arguments[0].getText();
const fragments = getAllFragments(filename, node, info);
-
text = `${queryText}\m${fragments.map(x => print(x)).join('\n')}`;
cursor = new Cursor(foundToken.line, foundToken.start - 1);
} else if (ts.isTaggedTemplateExpression(node)) {
const { template, tag } = node;
···
fragments = parsed.definitions.filter(
x => x.kind === Kind.FRAGMENT_DEFINITION
) as Array<FragmentDefinitionNode>;
-
} catch (e) {}
let suggestions = getAutocompleteSuggestions(schema, queryText, cursor);
let spreadSuggestions = getSuggestionsForFragmentSpread(
···
queryText,
fragments
);
const state =
token.state.kind === 'Invalid' ? token.state.prevState : token.state;
···
const foundToken = getToken(node.arguments[0], cursorPosition);
if (!schema.current || !foundToken) return undefined;
+
const queryText = node.arguments[0].getText().slice(1, -1);
const fragments = getAllFragments(filename, node, info);
+
text = `${queryText}\n${fragments.map(x => print(x)).join('\n')}`;
cursor = new Cursor(foundToken.line, foundToken.start - 1);
} else if (ts.isTaggedTemplateExpression(node)) {
const { template, tag } = node;
···
fragments = parsed.definitions.filter(
x => x.kind === Kind.FRAGMENT_DEFINITION
) as Array<FragmentDefinitionNode>;
+
} catch (e) {
+
console.log('[GraphQLSP] ', e);
+
}
+
console.log('fraggers', fragments.map(x => print(x)).join('\n'));
let suggestions = getAutocompleteSuggestions(schema, queryText, cursor);
let spreadSuggestions = getSuggestionsForFragmentSpread(
···
queryText,
fragments
);
+
console.log(JSON.stringify(spreadSuggestions, null, 2));
const state =
token.state.kind === 'Invalid' ? token.state.prevState : token.state;
+166 -2
packages/graphqlsp/src/checkImports.ts
···
import ts from 'typescript/lib/tsserverlibrary';
-
import { Kind, parse } from 'graphql';
-
import { findAllImports, findAllTaggedTemplateNodes } from './ast';
import { resolveTemplate } from './ast/resolve';
export const MISSING_FRAGMENT_CODE = 52003;
···
return tsDiagnostics;
};
···
import ts from 'typescript/lib/tsserverlibrary';
+
import { FragmentDefinitionNode, Kind, parse } from 'graphql';
+
import {
+
findAllCallExpressions,
+
findAllImports,
+
findAllTaggedTemplateNodes,
+
getSource,
+
} from './ast';
import { resolveTemplate } from './ast/resolve';
export const MISSING_FRAGMENT_CODE = 52003;
···
return tsDiagnostics;
};
+
+
export const getColocatedFragmentNames = (
+
source: ts.SourceFile,
+
info: ts.server.PluginCreateInfo
+
): Record<
+
string,
+
{ start: number; length: number; fragments: Array<string> }
+
> => {
+
const imports = findAllImports(source);
+
const importSpecifierToFragments: Record<
+
string,
+
{ start: number; length: number; fragments: Array<string> }
+
> = {};
+
+
if (imports.length) {
+
imports.forEach(imp => {
+
if (!imp.importClause) return;
+
+
if (imp.importClause.name) {
+
const definitions = info.languageService.getDefinitionAtPosition(
+
source.fileName,
+
imp.importClause.name.getStart()
+
);
+
if (definitions && definitions.length) {
+
const [def] = definitions;
+
if (def.fileName.includes('node_modules')) return;
+
+
const externalSource = getSource(info, def.fileName);
+
if (!externalSource) return;
+
+
const fragmentsForImport = getFragmentsInSource(externalSource, info);
+
+
const names = fragmentsForImport.map(fragment => fragment.name.value);
+
if (
+
names.length &&
+
!importSpecifierToFragments[imp.moduleSpecifier.getText()]
+
) {
+
importSpecifierToFragments[imp.moduleSpecifier.getText()] = {
+
start: imp.moduleSpecifier.getStart(),
+
length: imp.moduleSpecifier.getText().length,
+
fragments: names,
+
};
+
} else if (names.length) {
+
importSpecifierToFragments[
+
imp.moduleSpecifier.getText()
+
].fragments =
+
importSpecifierToFragments[
+
imp.moduleSpecifier.getText()
+
].fragments.concat(names);
+
}
+
}
+
}
+
+
if (
+
imp.importClause.namedBindings &&
+
ts.isNamespaceImport(imp.importClause.namedBindings)
+
) {
+
const definitions = info.languageService.getDefinitionAtPosition(
+
source.fileName,
+
imp.importClause.namedBindings.getStart()
+
);
+
if (definitions && definitions.length) {
+
const [def] = definitions;
+
if (def.fileName.includes('node_modules')) return;
+
+
const externalSource = getSource(info, def.fileName);
+
if (!externalSource) return;
+
+
const fragmentsForImport = getFragmentsInSource(externalSource, info);
+
const names = fragmentsForImport.map(fragment => fragment.name.value);
+
if (
+
names.length &&
+
!importSpecifierToFragments[imp.moduleSpecifier.getText()]
+
) {
+
importSpecifierToFragments[imp.moduleSpecifier.getText()] = {
+
start: imp.moduleSpecifier.getStart(),
+
length: imp.moduleSpecifier.getText().length,
+
fragments: names,
+
};
+
} else if (names.length) {
+
importSpecifierToFragments[
+
imp.moduleSpecifier.getText()
+
].fragments =
+
importSpecifierToFragments[
+
imp.moduleSpecifier.getText()
+
].fragments.concat(names);
+
}
+
}
+
} else if (
+
imp.importClause.namedBindings &&
+
ts.isNamedImportBindings(imp.importClause.namedBindings)
+
) {
+
imp.importClause.namedBindings.elements.forEach(el => {
+
const definitions = info.languageService.getDefinitionAtPosition(
+
source.fileName,
+
el.getStart()
+
);
+
if (definitions && definitions.length) {
+
const [def] = definitions;
+
if (def.fileName.includes('node_modules')) return;
+
+
const externalSource = getSource(info, def.fileName);
+
if (!externalSource) return;
+
+
const fragmentsForImport = getFragmentsInSource(
+
externalSource,
+
info
+
);
+
const names = fragmentsForImport.map(
+
fragment => fragment.name.value
+
);
+
if (
+
names.length &&
+
!importSpecifierToFragments[imp.moduleSpecifier.getText()]
+
) {
+
importSpecifierToFragments[imp.moduleSpecifier.getText()] = {
+
start: imp.moduleSpecifier.getStart(),
+
length: imp.moduleSpecifier.getText().length,
+
fragments: names,
+
};
+
} else if (names.length) {
+
importSpecifierToFragments[
+
imp.moduleSpecifier.getText()
+
].fragments =
+
importSpecifierToFragments[
+
imp.moduleSpecifier.getText()
+
].fragments.concat(names);
+
}
+
}
+
});
+
}
+
});
+
}
+
+
return importSpecifierToFragments;
+
};
+
+
function getFragmentsInSource(
+
src: ts.SourceFile,
+
info: ts.server.PluginCreateInfo
+
): Array<FragmentDefinitionNode> {
+
let fragments: Array<FragmentDefinitionNode> = [];
+
const tagTemplate = info.config.template || 'gql';
+
const callExpressions = findAllCallExpressions(src, tagTemplate, info, false);
+
+
callExpressions.nodes.forEach(node => {
+
const text = resolveTemplate(node, src.fileName, info).combinedText;
+
try {
+
const parsed = parse(text, { noLocation: true });
+
if (parsed.definitions.every(x => x.kind === Kind.FRAGMENT_DEFINITION)) {
+
fragments = fragments.concat(parsed.definitions as any);
+
}
+
} catch (e) {
+
return;
+
}
+
});
+
+
return fragments;
+
}
+70 -7
packages/graphqlsp/src/diagnostics.ts
···
OperationDefinitionNode,
parse,
print,
} from 'graphql';
import { LRUCache } from 'lru-cache';
import fnv1a from '@sindresorhus/fnv1a';
···
import { resolveTemplate } from './ast/resolve';
import { generateTypedDocumentNodes } from './graphql/generateTypes';
import { checkFieldUsageInFile } from './fieldUsage';
-
import { checkImportsForFragments } from './checkImports';
const clientDirectives = new Set([
'populate',
···
messageText: diag.message.split('\n')[0],
}));
-
const importDiagnostics = isCallExpression
-
? checkFieldUsageInFile(
source,
-
nodes as ts.NoSubstitutionTemplateLiteral[],
info
-
)
-
: checkImportsForFragments(source, info);
-
return [...tsDiagnostics, ...importDiagnostics];
};
const runTypedDocumentNodes = (
···
OperationDefinitionNode,
parse,
print,
+
visit,
} from 'graphql';
import { LRUCache } from 'lru-cache';
import fnv1a from '@sindresorhus/fnv1a';
···
import { resolveTemplate } from './ast/resolve';
import { generateTypedDocumentNodes } from './graphql/generateTypes';
import { checkFieldUsageInFile } from './fieldUsage';
+
import {
+
MISSING_FRAGMENT_CODE,
+
checkImportsForFragments,
+
getColocatedFragmentNames,
+
} from './checkImports';
const clientDirectives = new Set([
'populate',
···
messageText: diag.message.split('\n')[0],
}));
+
if (isCallExpression) {
+
const usageDiagnostics = checkFieldUsageInFile(
+
source,
+
nodes as ts.NoSubstitutionTemplateLiteral[],
+
info
+
);
+
+
const shouldCheckForColocatedFragments =
+
info.config.shouldCheckForColocatedFragments ?? false;
+
let fragmentDiagnostics: ts.Diagnostic[] = [];
+
console.log(
+
'[GraphhQLSP] Checking for colocated fragments ',
+
!!shouldCheckForColocatedFragments
+
);
+
if (shouldCheckForColocatedFragments) {
+
const moduleSpecifierToFragments = getColocatedFragmentNames(
source,
info
+
);
+
console.log(
+
'[GraphhQLSP] Checking for colocated fragments ',
+
JSON.stringify(moduleSpecifierToFragments, null, 2)
+
);
+
const usedFragments = new Set();
+
nodes.forEach(node => {
+
try {
+
const parsed = parse(node.getText().slice(1, -1), {
+
noLocation: true,
+
});
+
visit(parsed, {
+
FragmentSpread: node => {
+
usedFragments.add(node.name.value);
+
},
+
});
+
} catch (e) {}
+
});
+
+
Object.keys(moduleSpecifierToFragments).forEach(moduleSpecifier => {
+
const {
+
fragments: fragmentNames,
+
start,
+
length,
+
} = moduleSpecifierToFragments[moduleSpecifier];
+
const missingFragments = fragmentNames.filter(
+
x => !usedFragments.has(x)
+
);
+
if (missingFragments.length) {
+
fragmentDiagnostics.push({
+
file: source,
+
length,
+
start,
+
category: ts.DiagnosticCategory.Warning,
+
code: MISSING_FRAGMENT_CODE,
+
messageText: `Unused co-located fragment definition(s) "${missingFragments.join(
+
', '
+
)}" in ${moduleSpecifier}`,
+
});
+
}
+
});
+
}
+
+
return [...tsDiagnostics, ...usageDiagnostics, ...fragmentDiagnostics];
+
} else {
+
const importDiagnostics = checkImportsForFragments(source, info);
+
return [...tsDiagnostics, ...importDiagnostics];
+
}
};
const runTypedDocumentNodes = (
+82 -121
test/e2e/client-preset.test.ts
···
const projectPath = path.resolve(__dirname, 'fixture-project-client-preset');
describe('Fragment + operations', () => {
const outfileCombo = path.join(projectPath, 'simple.ts');
const outfileCombinations = path.join(projectPath, 'fragment.ts');
const outfileGql = path.join(projectPath, 'gql', 'gql.ts');
const outfileGraphql = path.join(projectPath, 'gql', 'graphql.ts');
···
} satisfies ts.server.protocol.OpenRequestArgs);
server.sendCommand('open', {
file: outfileCombinations,
fileContent: '// empty',
scriptKindName: 'TS',
} satisfies ts.server.protocol.OpenRequestArgs);
···
file: outfileCombo,
fileContent: fs.readFileSync(
path.join(projectPath, 'fixtures/simple.ts'),
'utf-8'
),
},
···
file: outfileCombinations,
tmpfile: outfileCombinations,
} satisfies ts.server.protocol.SavetoRequestArgs);
});
afterAll(() => {
try {
fs.unlinkSync(outfileCombinations);
fs.unlinkSync(outfileCombo);
fs.unlinkSync(outfileGql);
···
e => e.type === 'event' && e.event === 'semanticDiag'
);
const res = server.responses.filter(
-
resp => resp.type === 'event' && resp.event === 'semanticDiag'
);
expect(res[0].body.diagnostics).toMatchInlineSnapshot(`
[
···
expect(res?.body.entries).toMatchInlineSnapshot(`
[
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " AttacksConnection",
},
-
"name": "attacks",
-
"sortText": "0attacks",
},
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " [EvolutionRequirement]",
},
-
"name": "evolutionRequirements",
-
"sortText": "2evolutionRequirements",
},
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " [Pokemon]",
},
-
"name": "evolutions",
-
"sortText": "3evolutions",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"description": "Likelihood of an attempt to catch a Pokémon to fail.",
-
"detail": " Float",
},
-
"name": "fleeRate",
-
"sortText": "4fleeRate",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " PokemonDimension",
-
},
-
"name": "height",
-
"sortText": "5height",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " ID!",
-
},
-
"name": "id",
-
"sortText": "6id",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"description": "Maximum combat power a Pokémon may achieve at max level.",
-
"detail": " Int",
-
},
-
"name": "maxCP",
-
"sortText": "7maxCP",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"description": "Maximum health points a Pokémon may achieve at max level.",
-
"detail": " Int",
-
},
-
"name": "maxHP",
-
"sortText": "8maxHP",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " String!",
-
},
-
"name": "name",
-
"sortText": "9name",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " [PokemonType]",
-
},
-
"name": "resistant",
-
"sortText": "10resistant",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " [PokemonType]",
-
},
-
"name": "types",
-
"sortText": "11types",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " [PokemonType]",
-
},
-
"name": "weaknesses",
-
"sortText": "12weaknesses",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"detail": " PokemonDimension",
-
},
-
"name": "weight",
-
"sortText": "13weight",
-
},
-
{
-
"kind": "var",
-
"kindModifiers": "declare",
-
"labelDetails": {
-
"description": "The name of the current Object type at runtime.",
-
"detail": " String!",
-
},
-
"name": "__typename",
-
"sortText": "14__typename",
},
]
`);
···
const projectPath = path.resolve(__dirname, 'fixture-project-client-preset');
describe('Fragment + operations', () => {
const outfileCombo = path.join(projectPath, 'simple.ts');
+
const outfileUnusedFragment = path.join(projectPath, 'unused-fragment.ts');
const outfileCombinations = path.join(projectPath, 'fragment.ts');
const outfileGql = path.join(projectPath, 'gql', 'gql.ts');
const outfileGraphql = path.join(projectPath, 'gql', 'graphql.ts');
···
} satisfies ts.server.protocol.OpenRequestArgs);
server.sendCommand('open', {
file: outfileCombinations,
+
fileContent: '// empty',
+
scriptKindName: 'TS',
+
} satisfies ts.server.protocol.OpenRequestArgs);
+
server.sendCommand('open', {
+
file: outfileUnusedFragment,
fileContent: '// empty',
scriptKindName: 'TS',
} satisfies ts.server.protocol.OpenRequestArgs);
···
file: outfileCombo,
fileContent: fs.readFileSync(
path.join(projectPath, 'fixtures/simple.ts'),
+
'utf-8'
+
),
+
},
+
{
+
file: outfileUnusedFragment,
+
fileContent: fs.readFileSync(
+
path.join(projectPath, 'fixtures/unused-fragment.ts'),
'utf-8'
),
},
···
file: outfileCombinations,
tmpfile: outfileCombinations,
} satisfies ts.server.protocol.SavetoRequestArgs);
+
server.sendCommand('saveto', {
+
file: outfileUnusedFragment,
+
tmpfile: outfileUnusedFragment,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
});
afterAll(() => {
try {
+
fs.unlinkSync(outfileUnusedFragment);
fs.unlinkSync(outfileCombinations);
fs.unlinkSync(outfileCombo);
fs.unlinkSync(outfileGql);
···
e => e.type === 'event' && e.event === 'semanticDiag'
);
const res = server.responses.filter(
+
resp =>
+
resp.type === 'event' &&
+
resp.event === 'semanticDiag' &&
+
resp.body?.file === outfileCombo
);
expect(res[0].body.diagnostics).toMatchInlineSnapshot(`
[
···
expect(res?.body.entries).toMatchInlineSnapshot(`
[
{
+
"kind": "string",
+
"kindModifiers": "",
+
"name": "\\\\n fragment pokemonFields on Pokemon {\\\\n id\\\\n name\\\\n attacks {\\\\n fast {\\\\n damage\\\\n name\\\\n }\\\\n }\\\\n }\\\\n",
+
"replacementSpan": {
+
"end": {
+
"line": 9,
+
"offset": 1,
+
},
+
"start": {
+
"line": 3,
+
"offset": 39,
+
},
},
+
"sortText": "11",
},
{
+
"kind": "string",
+
"kindModifiers": "",
+
"name": "\\\\n query Pok($limit: Int!) {\\\\n pokemons(limit: $limit) {\\\\n id\\\\n name\\\\n fleeRate\\\\n classification\\\\n ...pokemonFields\\\\n ...weaknessFields\\\\n __typename\\\\n }\\\\n }\\\\n",
+
"replacementSpan": {
+
"end": {
+
"line": 9,
+
"offset": 1,
+
},
+
"start": {
+
"line": 3,
+
"offset": 39,
+
},
},
+
"sortText": "11",
},
+
]
+
`);
+
}, 30000);
+
+
it('gives semantic-diagnostics with unused fragments', async () => {
+
server.sendCommand('saveto', {
+
file: outfileUnusedFragment,
+
tmpfile: outfileUnusedFragment,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
+
+
await server.waitForResponse(
+
e =>
+
e.type === 'event' &&
+
e.event === 'semanticDiag' &&
+
e.body?.file === outfileUnusedFragment
+
);
+
+
const res = server.responses.filter(
+
resp =>
+
resp.type === 'event' &&
+
resp.event === 'semanticDiag' &&
+
resp.body?.file === outfileUnusedFragment
+
);
+
expect(res[0].body.diagnostics).toMatchInlineSnapshot(`
+
[
{
+
"category": "warning",
+
"code": 52003,
+
"end": {
+
"line": 2,
+
"offset": 37,
},
+
"start": {
+
"line": 2,
+
"offset": 25,
},
+
"text": "Unused co-located fragment definition(s) \\"pokemonFields\\" in './fragment'",
},
]
`);
+2 -1
test/e2e/fixture-project-client-preset/fixtures/fragment.ts
···
id
name
fleeRate
-
}
`);
···
id
name
fleeRate
}
`);
+
+
export const Pokemon = () => {};
+13
test/e2e/fixture-project-client-preset/fixtures/unused-fragment.ts
···
···
+
import { graphql } from './gql/gql';
+
import { Pokemon } from './fragment';
+
+
const x = graphql(`
+
query Pok($limit: Int!) {
+
pokemons(limit: $limit) {
+
id
+
name
+
}
+
}
+
`);
+
+
console.log(Pokemon);
+1 -1
test/e2e/fixture-project-client-preset/tsconfig.json
···
"name": "@0no-co/graphqlsp",
"schema": "./schema.graphql",
"disableTypegen": true,
-
"shouldCheckForColocatedFragments": false,
"template": "graphql",
"templateIsCallExpression": true
}
···
"name": "@0no-co/graphqlsp",
"schema": "./schema.graphql",
"disableTypegen": true,
+
"shouldCheckForColocatedFragments": true,
"template": "graphql",
"templateIsCallExpression": true
}