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

feat: provide diagnostic for unused colocated fragments (#42)

* provide message for unused fragments

* remove existing imports from diagnostics

* updates

* update readme

* add todo

* Update .changeset/sour-feet-hear.md

+6
.changeset/sour-feet-hear.md
···
+
---
+
'@0no-co/graphqlsp': minor
+
---
+
+
Add a `message` diagnostic when we see an import from a file that has `fragment` exports we'll warn you when they are not imported, this because of the assumption that to use this file one would have to adhere to the data requirements of said file.
+
You can choose to disable this setting by setting `shouldCheckForColocatedFragments` to `false`
+2 -2
.gitattributes
···
* text=auto
-
./**/*.generated.ts linguist-generated
-
./**/*.graphql linguist-generated
+
**/*.generated.ts linguist-generated
+
**/*.graphql linguist-generated
+1
README.md
···
- Diagnostics for adding fields that don't exist, are deprecated, missmatched argument types, ...
- Auto-complete inside your editor for fields
- When you save it will generate `typed-document-nodes` for your documents and cast them to the correct type
+
- Will warn you when you are importing from a file that is exporting fragments that you're not using
## Installation
+103 -24
packages/example/src/Pokemon.generated.ts
···
-
import * as Types from '../__generated__/baseGraphQLSP';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
-
export type FieldsFragment = {
+
export type Maybe<T> = T | null;
+
export type InputMaybe<T> = Maybe<T>;
+
export type Exact<T extends { [key: string]: unknown }> = {
+
[K in keyof T]: T[K];
+
};
+
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & {
+
[SubKey in K]?: Maybe<T[SubKey]>;
+
};
+
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & {
+
[SubKey in K]: Maybe<T[SubKey]>;
+
};
+
/** All built-in and custom scalars, mapped to their actual values */
+
export type Scalars = {
+
ID: string;
+
String: string;
+
Boolean: boolean;
+
Int: number;
+
Float: number;
+
};
+
+
/** Elemental property associated with either a Pokémon or one of their moves. */
+
export type PokemonType =
+
| 'Grass'
+
| 'Poison'
+
| 'Fire'
+
| 'Flying'
+
| 'Water'
+
| 'Bug'
+
| 'Normal'
+
| 'Electric'
+
| 'Ground'
+
| 'Fairy'
+
| 'Fighting'
+
| 'Psychic'
+
| 'Rock'
+
| 'Steel'
+
| 'Ice'
+
| 'Ghost'
+
| 'Dragon'
+
| 'Dark';
+
+
/** Move a Pokémon can perform with the associated damage and type. */
+
export type Attack = {
+
__typename?: 'Attack';
+
name?: Maybe<Scalars['String']>;
+
type?: Maybe<PokemonType>;
+
damage?: Maybe<Scalars['Int']>;
+
};
+
+
/** Requirement that prevents an evolution through regular means of levelling up. */
+
export type EvolutionRequirement = {
+
__typename?: 'EvolutionRequirement';
+
amount?: Maybe<Scalars['Int']>;
+
name?: Maybe<Scalars['String']>;
+
};
+
+
export type PokemonDimension = {
+
__typename?: 'PokemonDimension';
+
minimum?: Maybe<Scalars['String']>;
+
maximum?: Maybe<Scalars['String']>;
+
};
+
+
export type AttacksConnection = {
+
__typename?: 'AttacksConnection';
+
fast?: Maybe<Array<Maybe<Attack>>>;
+
special?: Maybe<Array<Maybe<Attack>>>;
+
};
+
+
export type Pokemon = {
__typename?: 'Pokemon';
-
classification?: string | null;
-
id: string;
+
id: Scalars['ID'];
+
name: Scalars['String'];
+
classification?: Maybe<Scalars['String']>;
+
types?: Maybe<Array<Maybe<PokemonType>>>;
+
resistant?: Maybe<Array<Maybe<PokemonType>>>;
+
weaknesses?: Maybe<Array<Maybe<PokemonType>>>;
+
evolutionRequirements?: Maybe<Array<Maybe<EvolutionRequirement>>>;
+
weight?: Maybe<PokemonDimension>;
+
height?: Maybe<PokemonDimension>;
+
attacks?: Maybe<AttacksConnection>;
+
/** Likelihood of an attempt to catch a Pokémon to fail. */
+
fleeRate?: Maybe<Scalars['Float']>;
+
/** Maximum combat power a Pokémon may achieve at max level. */
+
maxCP?: Maybe<Scalars['Int']>;
+
/** Maximum health points a Pokémon may achieve at max level. */
+
maxHP?: Maybe<Scalars['Int']>;
+
evolutions?: Maybe<Array<Maybe<Pokemon>>>;
+
};
+
+
export type Query = {
+
__typename?: 'Query';
+
/** List out all Pokémon, optionally in pages */
+
pokemons?: Maybe<Array<Maybe<Pokemon>>>;
+
/** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */
+
pokemon?: Maybe<Pokemon>;
+
};
+
+
export type QueryPokemonsArgs = {
+
limit?: InputMaybe<Scalars['Int']>;
+
skip?: InputMaybe<Scalars['Int']>;
+
};
+
+
export type QueryPokemonArgs = {
+
id: Scalars['ID'];
};
export type PokemonFieldsFragment = {
···
name: string;
};
-
export const FieldsFragmentDoc = {
-
kind: 'Document',
-
definitions: [
-
{
-
kind: 'FragmentDefinition',
-
name: { kind: 'Name', value: 'fields' },
-
typeCondition: {
-
kind: 'NamedType',
-
name: { kind: 'Name', value: 'Pokemon' },
-
},
-
selectionSet: {
-
kind: 'SelectionSet',
-
selections: [
-
{ kind: 'Field', name: { kind: 'Name', value: 'classification' } },
-
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
-
],
-
},
-
},
-
],
-
} as unknown as DocumentNode<FieldsFragment, unknown>;
export const PokemonFieldsFragmentDoc = {
kind: 'Document',
definitions: [
+2 -7
packages/example/src/Pokemon.ts
···
import { gql } from '@urql/core';
-
export const fields = gql`
-
fragment fields on Pokemon {
-
classification
-
id
-
}
-
` as typeof import('./Pokemon.generated').FieldsFragmentDoc;
-
export const PokemonFields = gql`
fragment pokemonFields on Pokemon {
id
name
}
` as typeof import('./Pokemon.generated').PokemonFieldsFragmentDoc;
+
+
export const Pokemon = () => 'hi';
-65
packages/example/src/index.generated.ts
···
} | null> | null;
};
-
export type PokemonFieldsFragment = {
-
__typename?: 'Pokemon';
-
id: string;
-
name: string;
-
};
-
export type PokemonQueryVariables = Types.Exact<{
id: Types.Scalars['ID'];
}>;
···
__typename: 'Pokemon';
id: string;
fleeRate?: number | null;
-
name: string;
} | null;
};
-
export const PokemonFieldsFragmentDoc = {
-
kind: 'Document',
-
definitions: [
-
{
-
kind: 'FragmentDefinition',
-
name: { kind: 'Name', value: 'pokemonFields' },
-
typeCondition: {
-
kind: 'NamedType',
-
name: { kind: 'Name', value: 'Pokemon' },
-
},
-
selectionSet: {
-
kind: 'SelectionSet',
-
selections: [
-
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
-
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
-
],
-
},
-
},
-
],
-
} as unknown as DocumentNode<PokemonFieldsFragment, unknown>;
export const PokemonsDocument = {
kind: 'Document',
definitions: [
···
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: '__typename' } },
{ kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } },
-
{
-
kind: 'FragmentSpread',
-
name: { kind: 'Name', value: 'pokemonFields' },
-
},
],
},
},
],
},
},
-
{
-
kind: 'FragmentDefinition',
-
name: { kind: 'Name', value: 'pokemonFields' },
-
typeCondition: {
-
kind: 'NamedType',
-
name: { kind: 'Name', value: 'Pokemon' },
-
},
-
selectionSet: {
-
kind: 'SelectionSet',
-
selections: [
-
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
-
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
-
],
-
},
-
},
],
} as unknown as DocumentNode<PokemonsQuery, PokemonsQueryVariables>;
export const PokemonDocument = {
···
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } },
-
{
-
kind: 'FragmentSpread',
-
name: { kind: 'Name', value: 'pokemonFields' },
-
},
{ kind: 'Field', name: { kind: 'Name', value: '__typename' } },
],
},
},
-
],
-
},
-
},
-
{
-
kind: 'FragmentDefinition',
-
name: { kind: 'Name', value: 'pokemonFields' },
-
typeCondition: {
-
kind: 'NamedType',
-
name: { kind: 'Name', value: 'Pokemon' },
-
},
-
selectionSet: {
-
kind: 'SelectionSet',
-
selections: [
-
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
-
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
+1 -7
packages/example/src/index.ts
···
import { gql, createClient } from '@urql/core';
-
import { PokemonFields } from './Pokemon';
+
import { Pokemon, PokemonFields } from './Pokemon';
const PokemonsQuery = gql`
query Pokemons {
···
name
__typename
fleeRate
-
...pokemonFields
}
}
-
-
${PokemonFields}
` as typeof import('./index.generated').PokemonsDocument;
const client = createClient({
···
pokemon(id: $id) {
id
fleeRate
-
...pokemonFields
__typename
}
}
-
-
${PokemonFields}
` as typeof import('./index.generated').PokemonDocument;
client
+8
packages/graphqlsp/README.md
···
- Diagnostics for adding fields that don't exist, are deprecated, missmatched argument types, ...
- Auto-complete inside your editor for fields
- When you save it will generate `typed-document-nodes` for your documents and cast them to the correct type
+
- Will warn you when you are importing from a file that is exporting fragments that you're not using
## Installation
···
```
now restart your TS-server and you should be good to go
+
+
### Configuration
+
+
- `schema` allows you to specify a url, `.json` or `.graphql` file as your schema
+
- `scalars` allows you to pass an object of scalars that we'll feed into `graphql-code-generator`
+
- `shouldCheckForColocatedFragments` when turned on (default), this will scan your imports to find
+
unused fragments and provide a message notifying you about them
## Local development
+45
packages/graphqlsp/src/ast.ts
···
+
import ts from 'typescript/lib/tsserverlibrary';
+
import {
+
isImportDeclaration,
+
isNoSubstitutionTemplateLiteral,
+
isTaggedTemplateExpression,
+
} from 'typescript';
+
+
export function findNode(
+
sourceFile: ts.SourceFile,
+
position: number
+
): ts.Node | undefined {
+
function find(node: ts.Node): ts.Node | undefined {
+
if (position >= node.getStart() && position < node.getEnd()) {
+
return ts.forEachChild(node, find) || node;
+
}
+
}
+
return find(sourceFile);
+
}
+
+
export function findAllTaggedTemplateNodes(
+
sourceFile: ts.SourceFile | ts.Node
+
): Array<ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral> {
+
const result: Array<
+
ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral
+
> = [];
+
function find(node: ts.Node) {
+
if (
+
isTaggedTemplateExpression(node) ||
+
isNoSubstitutionTemplateLiteral(node)
+
) {
+
result.push(node);
+
return;
+
} else {
+
ts.forEachChild(node, find);
+
}
+
}
+
find(sourceFile);
+
return result;
+
}
+
+
export function findAllImports(
+
sourceFile: ts.SourceFile
+
): Array<ts.ImportDeclaration> {
+
return sourceFile.statements.filter(isImportDeclaration);
+
}
+110 -9
packages/graphqlsp/src/index.ts
···
isTemplateExpression,
isImportTypeNode,
ImportTypeNode,
-
CompletionEntry,
+
isNamespaceImport,
+
isNamedImportBindings,
} from 'typescript';
import {
getHoverInformation,
···
OperationDefinitionNode,
} from 'graphql';
+
import { findAllImports, findAllTaggedTemplateNodes, findNode } from './ast';
import { Cursor } from './cursor';
import { loadSchema } from './getSchema';
import { getToken } from './token';
import {
-
findAllTaggedTemplateNodes,
-
findNode,
getSource,
getSuggestionsForFragmentSpread,
isFileDirty,
···
const tagTemplate = info.config.template || 'gql';
const scalars = info.config.scalars || {};
+
const shouldCheckForColocatedFragments =
+
info.config.shouldCheckForColocatedFragments || true;
const proxy = createBasicDecorator(info);
···
return result;
});
-
if (!newDiagnostics.length) {
+
const imports = findAllImports(source);
+
if (imports.length && shouldCheckForColocatedFragments) {
+
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
+
imports.forEach(imp => {
+
if (!imp.importClause) return;
+
+
const importedNames: string[] = [];
+
if (imp.importClause.name) {
+
importedNames.push(imp.importClause?.name.text);
+
}
+
+
if (
+
imp.importClause.namedBindings &&
+
isNamespaceImport(imp.importClause.namedBindings)
+
) {
+
// TODO: we might need to warn here when the fragment is unused as a namespace import
+
return;
+
} else if (
+
imp.importClause.namedBindings &&
+
isNamedImportBindings(imp.importClause.namedBindings)
+
) {
+
imp.importClause.namedBindings.elements.forEach(el => {
+
importedNames.push(el.name.text);
+
});
+
}
+
+
const symbol = typeChecker?.getSymbolAtLocation(imp.moduleSpecifier);
+
if (!symbol) return;
+
+
const moduleExports = typeChecker?.getExportsOfModule(symbol);
+
if (!moduleExports) return;
+
+
const missingImports = moduleExports
+
.map(exp => {
+
if (importedNames.includes(exp.name)) {
+
return;
+
}
+
+
const declarations = exp.getDeclarations();
+
const declaration = declarations?.find(x => {
+
// TODO: check whether the sourceFile.fileName resembles the module
+
// specifier
+
return true;
+
});
+
+
if (!declaration) return;
+
+
const [template] = findAllTaggedTemplateNodes(declaration);
+
if (template) {
+
let node = template;
+
if (
+
isNoSubstitutionTemplateLiteral(node) ||
+
isTemplateExpression(node)
+
) {
+
if (isTaggedTemplateExpression(node.parent)) {
+
node = node.parent;
+
} else {
+
return;
+
}
+
}
+
+
const text = resolveTemplate(
+
node,
+
node.getSourceFile().fileName,
+
info
+
);
+
const parsed = parse(text);
+
if (
+
parsed.definitions.every(
+
x => x.kind === Kind.FRAGMENT_DEFINITION
+
)
+
) {
+
return `'${exp.name}'`;
+
}
+
}
+
})
+
.filter(Boolean);
+
+
if (missingImports.length) {
+
// TODO: we could use getCodeFixesAtPosition
+
// to build on this
+
newDiagnostics.push({
+
file: source,
+
length: imp.getText().length,
+
start: imp.getStart(),
+
category: ts.DiagnosticCategory.Message,
+
code: 51001,
+
messageText: `Missing Fragment import(s) ${missingImports.join(
+
', '
+
)} from ${imp.moduleSpecifier.getText()}.`,
+
});
+
}
+
});
+
}
+
+
if (
+
!newDiagnostics.filter(
+
x =>
+
x.category === ts.DiagnosticCategory.Error ||
+
x.category === ts.DiagnosticCategory.Warning
+
).length
+
) {
try {
const parts = source.fileName.split('/');
const name = parts[parts.length - 1];
···
if (typeImport) {
// We only want the oldExportName here to be present
// that way we can diff its length vs the new one
-
const oldExportName = typeImport
-
.getText()
-
.split('.')
-
.pop()
+
const oldExportName = typeImport.getText().split('.').pop();
// Remove ` as ` from the beginning,
// this because getText() gives us everything
···
// around.
imp = imp.slice(4);
text = source.text.replace(typeImport.getText(), imp);
-
span.length = imp.length + ((oldExportName || '').length - exportName.length);
+
span.length =
+
imp.length + ((oldExportName || '').length - exportName.length);
} else {
text =
source.text.substring(0, span.start) +
+2 -1
packages/graphqlsp/src/resolve.ts
···
TaggedTemplateExpression,
} from 'typescript';
import ts from 'typescript/lib/tsserverlibrary';
-
import { findNode, getSource } from './utils';
+
import { findNode } from './ast';
+
import { getSource } from './utils';
export function resolveTemplate(
node: TaggedTemplateExpression,
-37
packages/graphqlsp/src/utils.ts
···
import ts from 'typescript/lib/tsserverlibrary';
-
import {
-
isNoSubstitutionTemplateLiteral,
-
isTaggedTemplateExpression,
-
} from 'typescript';
import fs from 'fs';
import {
CompletionItem,
···
const currentText = source.getFullText();
return currentText !== contents;
-
}
-
-
export function findNode(
-
sourceFile: ts.SourceFile,
-
position: number
-
): ts.Node | undefined {
-
function find(node: ts.Node): ts.Node | undefined {
-
if (position >= node.getStart() && position < node.getEnd()) {
-
return ts.forEachChild(node, find) || node;
-
}
-
}
-
return find(sourceFile);
-
}
-
-
export function findAllTaggedTemplateNodes(
-
sourceFile: ts.SourceFile
-
): Array<ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral> {
-
const result: Array<
-
ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral
-
> = [];
-
function find(node: ts.Node) {
-
if (
-
isTaggedTemplateExpression(node) ||
-
isNoSubstitutionTemplateLiteral(node)
-
) {
-
result.push(node);
-
return;
-
} else {
-
ts.forEachChild(node, find);
-
}
-
}
-
find(sourceFile);
-
return result;
}
export function getSource(info: ts.server.PluginCreateInfo, filename: string) {