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

Refactors (#45)

* refactor autocomplete

* quick info

* move ast-utils in folder

* refactor diagnostics

* moving stuff around

* hoist template bubble up func

* refactor diagnostics

* refactor test suite

* add test for missing fragment import

* execute tests single threaded to ensure correctnes

* please work

+1
.gitignore
···
lerna-debug.log*
src/**/*.js
example/src/**/*.js
+
test/e2e/fixture-project/__generated__/*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+1 -1
package.json
···
"prepare": "husky install",
"dev": "pnpm --filter @0no-co/graphqlsp dev",
"launch-debug": "./scripts/launch-debug.sh",
-
"test:e2e": "vitest run"
+
"test:e2e": "vitest run --single-thread"
},
"prettier": {
"singleQuote": true,
+50 -49
packages/example/__generated__/baseGraphQLSP.ts
···
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';
+
damage?: Maybe<Scalars['Int']>;
name?: Maybe<Scalars['String']>;
type?: Maybe<PokemonType>;
-
damage?: Maybe<Scalars['Int']>;
+
};
+
+
export type AttacksConnection = {
+
__typename?: 'AttacksConnection';
+
fast?: Maybe<Array<Maybe<Attack>>>;
+
special?: Maybe<Array<Maybe<Attack>>>;
};
/** Requirement that prevents an evolution through regular means of levelling up. */
···
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';
-
id: Scalars['ID'];
-
name: Scalars['String'];
+
attacks?: Maybe<AttacksConnection>;
+
/** @deprecated And this is the reason why */
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>;
+
evolutions?: Maybe<Array<Maybe<Pokemon>>>;
/** Likelihood of an attempt to catch a Pokémon to fail. */
fleeRate?: Maybe<Scalars['Float']>;
+
height?: Maybe<PokemonDimension>;
+
id: Scalars['ID'];
/** 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>>>;
+
name: Scalars['String'];
+
resistant?: Maybe<Array<Maybe<PokemonType>>>;
+
types?: Maybe<Array<Maybe<PokemonType>>>;
+
weaknesses?: Maybe<Array<Maybe<PokemonType>>>;
+
weight?: Maybe<PokemonDimension>;
+
};
+
+
export type PokemonDimension = {
+
__typename?: 'PokemonDimension';
+
maximum?: Maybe<Scalars['String']>;
+
minimum?: Maybe<Scalars['String']>;
};
+
/** Elemental property associated with either a Pokémon or one of their moves. */
+
export type PokemonType =
+
| 'Bug'
+
| 'Dark'
+
| 'Dragon'
+
| 'Electric'
+
| 'Fairy'
+
| 'Fighting'
+
| 'Fire'
+
| 'Flying'
+
| 'Ghost'
+
| 'Grass'
+
| 'Ground'
+
| 'Ice'
+
| 'Normal'
+
| 'Poison'
+
| 'Psychic'
+
| 'Rock'
+
| 'Steel'
+
| 'Water';
+
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>;
+
/** List out all Pokémon, optionally in pages */
+
pokemons?: Maybe<Array<Maybe<Pokemon>>>;
+
};
+
+
export type QueryPokemonArgs = {
+
id: Scalars['ID'];
};
export type QueryPokemonsArgs = {
limit?: InputMaybe<Scalars['Int']>;
skip?: InputMaybe<Scalars['Int']>;
};
-
-
export type QueryPokemonArgs = {
-
id: Scalars['ID'];
-
};
+1 -106
packages/example/src/Pokemon.generated.ts
···
+
import * as Types from '../__generated__/baseGraphQLSP';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
-
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';
-
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 = {
__typename?: 'Pokemon';
id: string;
+1 -1
packages/example/tsconfig.json
···
"plugins": [
{
"name": "@0no-co/graphqlsp",
-
"schema": "https://trygql.formidable.dev/graphql/basic-pokedex"
+
"schema": "./schema.graphql"
}
],
/* Language and Environment */
+32
packages/graphqlsp/src/ast.ts packages/graphqlsp/src/ast/index.ts
···
isImportDeclaration,
isNoSubstitutionTemplateLiteral,
isTaggedTemplateExpression,
+
isTemplateExpression,
+
isToken,
} from 'typescript';
+
import fs from 'fs';
+
+
export function isFileDirty(fileName: string, source: ts.SourceFile) {
+
const contents = fs.readFileSync(fileName, 'utf-8');
+
const currentText = source.getFullText();
+
+
return currentText !== contents;
+
}
+
+
export function getSource(info: ts.server.PluginCreateInfo, filename: string) {
+
const program = info.languageService.getProgram();
+
if (!program) return undefined;
+
+
const source = program.getSourceFile(filename);
+
if (!source) return undefined;
+
+
return source;
+
}
export function findNode(
sourceFile: ts.SourceFile,
···
): Array<ts.ImportDeclaration> {
return sourceFile.statements.filter(isImportDeclaration);
}
+
+
export function bubbleUpTemplate(node: ts.Node): ts.Node {
+
while (
+
isNoSubstitutionTemplateLiteral(node) ||
+
isToken(node) ||
+
isTemplateExpression(node)
+
) {
+
node = node.parent;
+
}
+
+
return node;
+
}
+103
packages/graphqlsp/src/autoComplete.ts
···
+
import ts from 'typescript/lib/tsserverlibrary';
+
import {
+
ScriptElementKind,
+
isIdentifier,
+
isTaggedTemplateExpression,
+
} from 'typescript';
+
import {
+
getAutocompleteSuggestions,
+
getTokenAtPosition,
+
getTypeInfo,
+
} from 'graphql-language-service';
+
import { FragmentDefinitionNode, GraphQLSchema, Kind, parse } from 'graphql';
+
+
import { bubbleUpTemplate, findNode, getSource } from './ast';
+
import { Cursor } from './ast/cursor';
+
import { resolveTemplate } from './ast/resolve';
+
import { getToken } from './ast/token';
+
import { getSuggestionsForFragmentSpread } from './graphql/getFragmentSpreadSuggestions';
+
+
export function getGraphQLCompletions(
+
filename: string,
+
cursorPosition: number,
+
schema: { current: GraphQLSchema | null },
+
info: ts.server.PluginCreateInfo
+
): ts.WithMetadata<ts.CompletionInfo> | undefined {
+
const tagTemplate = info.config.template || 'gql';
+
+
const source = getSource(info, filename);
+
if (!source) return undefined;
+
+
let node = findNode(source, cursorPosition);
+
if (!node) return undefined;
+
+
node = bubbleUpTemplate(node);
+
+
if (isTaggedTemplateExpression(node)) {
+
const { template, tag } = node;
+
+
if (!isIdentifier(tag) || tag.text !== tagTemplate) return undefined;
+
+
const foundToken = getToken(template, cursorPosition);
+
if (!foundToken || !schema.current) return undefined;
+
+
const text = resolveTemplate(node, filename, info);
+
let fragments: Array<FragmentDefinitionNode> = [];
+
try {
+
const parsed = parse(text);
+
fragments = parsed.definitions.filter(
+
x => x.kind === Kind.FRAGMENT_DEFINITION
+
) as Array<FragmentDefinitionNode>;
+
} catch (e) {}
+
+
const cursor = new Cursor(foundToken.line, foundToken.start);
+
const suggestions = getAutocompleteSuggestions(
+
schema.current,
+
text,
+
cursor
+
);
+
+
const token = getTokenAtPosition(text, cursor);
+
const spreadSuggestions = getSuggestionsForFragmentSpread(
+
token,
+
getTypeInfo(schema.current, token.state),
+
schema.current,
+
text,
+
fragments
+
);
+
+
return {
+
isGlobalCompletion: false,
+
isMemberCompletion: false,
+
isNewIdentifierLocation: false,
+
entries: [
+
...suggestions.map(suggestion => ({
+
...suggestion,
+
kind: ScriptElementKind.variableElement,
+
name: suggestion.label,
+
kindModifiers: 'declare',
+
sortText: suggestion.sortText || '0',
+
labelDetails: {
+
detail: suggestion.type
+
? ' ' + suggestion.type?.toString()
+
: undefined,
+
description: suggestion.documentation,
+
},
+
})),
+
...spreadSuggestions.map(suggestion => ({
+
...suggestion,
+
kind: ScriptElementKind.variableElement,
+
name: suggestion.label,
+
insertText: '...' + suggestion.label,
+
kindModifiers: 'declare',
+
sortText: '0',
+
labelDetails: {
+
description: suggestion.documentation,
+
},
+
})),
+
],
+
};
+
} else {
+
return undefined;
+
}
+
}
packages/graphqlsp/src/cursor.ts packages/graphqlsp/src/ast/cursor.ts
+337
packages/graphqlsp/src/diagnostics.ts
···
+
import ts from 'typescript/lib/tsserverlibrary';
+
import {
+
ImportTypeNode,
+
isImportTypeNode,
+
isNamedImportBindings,
+
isNamespaceImport,
+
isNoSubstitutionTemplateLiteral,
+
isTaggedTemplateExpression,
+
isTemplateExpression,
+
} from 'typescript';
+
import { Diagnostic, getDiagnostics } from 'graphql-language-service';
+
import {
+
FragmentDefinitionNode,
+
GraphQLSchema,
+
Kind,
+
OperationDefinitionNode,
+
parse,
+
} from 'graphql';
+
+
import {
+
findAllImports,
+
findAllTaggedTemplateNodes,
+
getSource,
+
isFileDirty,
+
} from './ast';
+
import { resolveTemplate } from './ast/resolve';
+
import { generateTypedDocumentNodes } from './graphql/generateTypes';
+
+
export const SEMANTIC_DIAGNOSTIC_CODE = 51001;
+
+
export function getGraphQLDiagnostics(
+
filename: string,
+
baseTypesPath: string,
+
schema: { current: GraphQLSchema | null },
+
info: ts.server.PluginCreateInfo
+
): ts.Diagnostic[] | undefined {
+
const tagTemplate = info.config.template || 'gql';
+
const scalars = info.config.scalars || {};
+
const shouldCheckForColocatedFragments =
+
info.config.shouldCheckForColocatedFragments || true;
+
+
const source = getSource(info, filename);
+
if (!source) return undefined;
+
+
const nodes = findAllTaggedTemplateNodes(source);
+
+
const texts = nodes.map(node => {
+
if (isNoSubstitutionTemplateLiteral(node) || isTemplateExpression(node)) {
+
if (isTaggedTemplateExpression(node.parent)) {
+
node = node.parent;
+
} else {
+
return undefined;
+
}
+
}
+
+
return resolveTemplate(node, filename, info);
+
});
+
+
const diagnostics = nodes
+
.map(originalNode => {
+
let node = originalNode;
+
if (isNoSubstitutionTemplateLiteral(node) || isTemplateExpression(node)) {
+
if (isTaggedTemplateExpression(node.parent)) {
+
node = node.parent;
+
} else {
+
return undefined;
+
}
+
}
+
+
const text = resolveTemplate(node, filename, info);
+
const lines = text.split('\n');
+
+
let startingPosition = node.pos + (tagTemplate.length + 1);
+
const graphQLDiagnostics = getDiagnostics(text, schema.current).map(x => {
+
const { start, end } = x.range;
+
+
// We add the start.line to account for newline characters which are
+
// split out
+
let startChar = startingPosition + start.line;
+
for (let i = 0; i <= start.line; i++) {
+
if (i === start.line) startChar += start.character;
+
else startChar += lines[i].length;
+
}
+
+
let endChar = startingPosition + end.line;
+
for (let i = 0; i <= end.line; i++) {
+
if (i === end.line) endChar += end.character;
+
else endChar += lines[i].length;
+
}
+
+
// We add 1 to the start because the range is exclusive of start.character
+
return { ...x, start: startChar + 1, length: endChar - startChar };
+
});
+
+
try {
+
const parsed = parse(text);
+
+
if (
+
parsed.definitions.some(x => x.kind === Kind.OPERATION_DEFINITION)
+
) {
+
const op = parsed.definitions.find(
+
x => x.kind === Kind.OPERATION_DEFINITION
+
) as OperationDefinitionNode;
+
if (!op.name) {
+
graphQLDiagnostics.push({
+
message: 'Operation needs a name for types to be generated.',
+
start: node.pos,
+
length: originalNode.getText().length,
+
range: {} as any,
+
severity: 2,
+
} as any);
+
}
+
}
+
} catch (e) {}
+
+
return graphQLDiagnostics;
+
})
+
.flat()
+
.filter(Boolean) as Array<Diagnostic & { length: number; start: number }>;
+
+
const tsDiagnostics: ts.Diagnostic[] = diagnostics.map(diag => ({
+
file: source,
+
length: diag.length,
+
start: diag.start,
+
category:
+
diag.severity === 2
+
? ts.DiagnosticCategory.Warning
+
: ts.DiagnosticCategory.Error,
+
code: SEMANTIC_DIAGNOSTIC_CODE,
+
messageText: diag.message.split('\n')[0],
+
}));
+
+
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
+
tsDiagnostics.push({
+
file: source,
+
length: imp.getText().length,
+
start: imp.getStart(),
+
category: ts.DiagnosticCategory.Message,
+
code: SEMANTIC_DIAGNOSTIC_CODE,
+
messageText: `Missing Fragment import(s) ${missingImports.join(
+
', '
+
)} from ${imp.moduleSpecifier.getText()}.`,
+
});
+
}
+
});
+
}
+
+
if (
+
!tsDiagnostics.filter(
+
x =>
+
x.category === ts.DiagnosticCategory.Error ||
+
x.category === ts.DiagnosticCategory.Warning
+
).length
+
) {
+
try {
+
if (isFileDirty(filename, source)) {
+
return tsDiagnostics;
+
}
+
+
const parts = source.fileName.split('/');
+
const name = parts[parts.length - 1];
+
const nameParts = name.split('.');
+
nameParts[nameParts.length - 1] = 'generated.ts';
+
parts[parts.length - 1] = nameParts.join('.');
+
+
generateTypedDocumentNodes(
+
schema.current,
+
parts.join('/'),
+
texts.join('\n'),
+
scalars,
+
baseTypesPath
+
).then(() => {
+
if (isFileDirty(filename, source)) {
+
return;
+
}
+
+
nodes.forEach((node, i) => {
+
const queryText = texts[i] || '';
+
const parsed = parse(queryText);
+
const isFragment = parsed.definitions.every(
+
x => x.kind === Kind.FRAGMENT_DEFINITION
+
);
+
let name = '';
+
+
if (isFragment) {
+
const fragmentNode = parsed
+
.definitions[0] as FragmentDefinitionNode;
+
name = fragmentNode.name.value;
+
} else {
+
const operationNode = parsed
+
.definitions[0] as OperationDefinitionNode;
+
name = operationNode.name?.value || '';
+
}
+
+
if (!name) return;
+
+
name = name.charAt(0).toUpperCase() + name.slice(1);
+
const parentChildren = node.parent.getChildren();
+
+
const exportName = isFragment
+
? `${name}FragmentDoc`
+
: `${name}Document`;
+
let imp = ` as typeof import('./${nameParts
+
.join('.')
+
.replace('.ts', '')}').${exportName}`;
+
+
// This checks whether one of the children is an import-type
+
// which is a short-circuit if there is no as
+
const typeImport = parentChildren.find(x =>
+
isImportTypeNode(x)
+
) as ImportTypeNode;
+
+
if (typeImport && typeImport.getText().includes(exportName)) return;
+
+
const span = { length: 1, start: node.end };
+
+
let text = '';
+
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();
+
+
// Remove ` as ` from the beginning,
+
// this because getText() gives us everything
+
// but ` as ` meaning we need to keep that part
+
// around.
+
imp = imp.slice(4);
+
text = source.text.replace(typeImport.getText(), imp);
+
span.length =
+
imp.length + ((oldExportName || '').length - exportName.length);
+
} else {
+
text =
+
source.text.substring(0, span.start) +
+
imp +
+
source.text.substring(
+
span.start + span.length,
+
source.text.length
+
);
+
}
+
+
const scriptInfo =
+
info.project.projectService.getScriptInfo(filename);
+
const snapshot = scriptInfo!.getSnapshot();
+
+
source.update(text, { span, newLength: imp.length });
+
scriptInfo!.editContent(0, snapshot.getLength(), text);
+
info.languageServiceHost.writeFile!(source.fileName, text);
+
if (!!typeImport) {
+
// To update the types, otherwise data is stale
+
scriptInfo!.reloadFromFile();
+
}
+
scriptInfo!.registerFileUpdate();
+
});
+
});
+
} catch (e) {}
+
}
+
+
return tsDiagnostics;
+
}
+2 -2
packages/graphqlsp/src/getSchema.ts packages/graphqlsp/src/graphql/getSchema.ts
···
import path from 'path';
import fs from 'fs';
-
import { Logger } from './index';
-
import { generateBaseTypes } from './types/generate';
+
import { Logger } from '../index';
+
import { generateBaseTypes } from './generateTypes';
export const loadSchema = (
root: string,
+31 -472
packages/graphqlsp/src/index.ts
···
import ts from 'typescript/lib/tsserverlibrary';
-
import {
-
isNoSubstitutionTemplateLiteral,
-
ScriptElementKind,
-
isIdentifier,
-
isTaggedTemplateExpression,
-
isToken,
-
isTemplateExpression,
-
isImportTypeNode,
-
ImportTypeNode,
-
isNamespaceImport,
-
isNamedImportBindings,
-
} from 'typescript';
-
import {
-
getHoverInformation,
-
getAutocompleteSuggestions,
-
getDiagnostics,
-
Diagnostic,
-
getTokenAtPosition,
-
getTypeInfo,
-
} from 'graphql-language-service';
-
import {
-
parse,
-
Kind,
-
FragmentDefinitionNode,
-
OperationDefinitionNode,
-
} from 'graphql';
-
import { findAllImports, findAllTaggedTemplateNodes, findNode } from './ast';
-
import { Cursor } from './cursor';
-
import { loadSchema } from './getSchema';
-
import { getToken } from './token';
-
import {
-
getSource,
-
getSuggestionsForFragmentSpread,
-
isFileDirty,
-
} from './utils';
-
import { resolveTemplate } from './resolve';
-
import { generateTypedDocumentNodes } from './types/generate';
+
import { loadSchema } from './graphql/getSchema';
+
import { getGraphQLCompletions } from './autoComplete';
+
import { getGraphQLQuickInfo } from './quickInfo';
+
import { getGraphQLDiagnostics } from './diagnostics';
function createBasicDecorator(info: ts.server.PluginCreateInfo) {
const proxy: ts.LanguageService = Object.create(null);
···
logger('Setting up the GraphQL Plugin');
-
const tagTemplate = info.config.template || 'gql';
const scalars = info.config.scalars || {};
-
const shouldCheckForColocatedFragments =
-
info.config.shouldCheckForColocatedFragments || true;
const proxy = createBasicDecorator(info);
const baseTypesPath =
info.project.getCurrentDirectory() + '/__generated__/baseGraphQLSP.ts';
+
const schema = loadSchema(
info.project.getProjectName(),
info.config.schema,
···
proxy.getSemanticDiagnostics = (filename: string): ts.Diagnostic[] => {
const originalDiagnostics =
info.languageService.getSemanticDiagnostics(filename);
-
const source = getSource(info, filename);
-
if (!source) return originalDiagnostics;
-
-
const nodes = findAllTaggedTemplateNodes(source);
-
-
const texts = nodes.map(node => {
-
if (isNoSubstitutionTemplateLiteral(node) || isTemplateExpression(node)) {
-
if (isTaggedTemplateExpression(node.parent)) {
-
node = node.parent;
-
} else {
-
return undefined;
-
}
-
}
-
-
return resolveTemplate(node, filename, info);
-
});
-
-
const diagnostics = nodes
-
.map(x => {
-
let node = x;
-
if (
-
isNoSubstitutionTemplateLiteral(node) ||
-
isTemplateExpression(node)
-
) {
-
if (isTaggedTemplateExpression(node.parent)) {
-
node = node.parent;
-
} else {
-
return undefined;
-
}
-
}
-
-
const text = resolveTemplate(node, filename, info);
-
const lines = text.split('\n');
-
-
let startingPosition = node.pos + (tagTemplate.length + 1);
-
const graphQLDiagnostics = getDiagnostics(text, schema.current).map(
-
x => {
-
const { start, end } = x.range;
-
-
// We add the start.line to account for newline characters which are
-
// split out
-
let startChar = startingPosition + start.line;
-
for (let i = 0; i <= start.line; i++) {
-
if (i === start.line) startChar += start.character;
-
else startChar += lines[i].length;
-
}
-
-
let endChar = startingPosition + end.line;
-
for (let i = 0; i <= end.line; i++) {
-
if (i === end.line) endChar += end.character;
-
else endChar += lines[i].length;
-
}
-
-
// We add 1 to the start because the range is exclusive of start.character
-
return { ...x, start: startChar + 1, length: endChar - startChar };
-
}
-
);
-
-
const parsed = parse(text);
-
-
if (
-
parsed.definitions.some(x => x.kind === Kind.OPERATION_DEFINITION)
-
) {
-
const op = parsed.definitions.find(
-
x => x.kind === Kind.OPERATION_DEFINITION
-
) as OperationDefinitionNode;
-
if (!op.name) {
-
graphQLDiagnostics.push({
-
message: 'Operation needs a name for types to be generated.',
-
start: node.pos,
-
length: x.getText().length,
-
range: {} as any,
-
severity: 2,
-
} as any);
-
}
-
}
-
-
return graphQLDiagnostics;
-
})
-
.flat()
-
.filter(Boolean) as Array<Diagnostic & { length: number; start: number }>;
-
-
const newDiagnostics = diagnostics.map(diag => {
-
const result: ts.Diagnostic = {
-
file: source,
-
length: diag.length,
-
start: diag.start,
-
category:
-
diag.severity === 2
-
? ts.DiagnosticCategory.Warning
-
: ts.DiagnosticCategory.Error,
-
code: 51001,
-
messageText: diag.message.split('\n')[0],
-
};
-
-
return result;
-
});
-
-
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];
-
const nameParts = name.split('.');
-
nameParts[nameParts.length - 1] = 'generated.ts';
-
parts[parts.length - 1] = nameParts.join('.');
-
-
if (isFileDirty(filename, source)) {
-
return [...newDiagnostics, ...originalDiagnostics];
-
}
-
-
generateTypedDocumentNodes(
-
schema.current,
-
parts.join('/'),
-
texts.join('\n'),
-
scalars,
-
baseTypesPath
-
).then(() => {
-
if (isFileDirty(filename, source)) {
-
return;
-
}
-
-
nodes.forEach((node, i) => {
-
const queryText = texts[i] || '';
-
const parsed = parse(queryText);
-
const isFragment = parsed.definitions.every(
-
x => x.kind === Kind.FRAGMENT_DEFINITION
-
);
-
let name = '';
-
-
if (isFragment) {
-
const fragmentNode = parsed
-
.definitions[0] as FragmentDefinitionNode;
-
name = fragmentNode.name.value;
-
} else {
-
const operationNode = parsed
-
.definitions[0] as OperationDefinitionNode;
-
name = operationNode.name?.value || '';
-
}
-
-
if (!name) return;
-
-
name = name.charAt(0).toUpperCase() + name.slice(1);
-
const parentChildren = node.parent.getChildren();
-
-
const exportName = isFragment
-
? `${name}FragmentDoc`
-
: `${name}Document`;
-
let imp = ` as typeof import('./${nameParts
-
.join('.')
-
.replace('.ts', '')}').${exportName}`;
-
-
// This checks whether one of the children is an import-type
-
// which is a short-circuit if there is no as
-
const typeImport = parentChildren.find(x =>
-
isImportTypeNode(x)
-
) as ImportTypeNode;
-
-
if (typeImport && typeImport.getText().includes(exportName)) return;
-
-
const span = { length: 1, start: node.end };
-
-
let text = '';
-
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();
-
-
// Remove ` as ` from the beginning,
-
// this because getText() gives us everything
-
// but ` as ` meaning we need to keep that part
-
// around.
-
imp = imp.slice(4);
-
text = source.text.replace(typeImport.getText(), imp);
-
span.length =
-
imp.length + ((oldExportName || '').length - exportName.length);
-
} else {
-
text =
-
source.text.substring(0, span.start) +
-
imp +
-
source.text.substring(
-
span.start + span.length,
-
source.text.length
-
);
-
}
-
-
const scriptInfo =
-
info.project.projectService.getScriptInfo(filename);
-
const snapshot = scriptInfo!.getSnapshot();
-
-
// TODO: potential optimisation is to write only one script-update
-
source.update(text, { span, newLength: imp.length });
-
scriptInfo!.editContent(0, snapshot.getLength(), text);
-
info.languageServiceHost.writeFile!(source.fileName, text);
-
if (!!typeImport) {
-
// To update the types, otherwise data is stale
-
scriptInfo!.reloadFromFile();
-
}
-
scriptInfo!.registerFileUpdate();
-
// script info contains a lot of utils that might come in handy here
-
// to save even if the user has local changes, if we could make that work
-
// that would be a win. If not we should check if we can figure it out through
-
// the script-info whether there are unsaved changes and not run this
-
// scriptInfo!.open(text);
-
});
-
});
-
} catch (e) {}
-
}
-
-
return [...newDiagnostics, ...originalDiagnostics];
+
const graphQLDiagnostics = getGraphQLDiagnostics(
+
filename,
+
baseTypesPath,
+
schema,
+
info
+
);
+
return graphQLDiagnostics
+
? [...graphQLDiagnostics, ...originalDiagnostics]
+
: originalDiagnostics;
};
proxy.getCompletionsAtPosition = (
···
entries: [],
};
-
const source = getSource(info, filename);
-
if (!source) return originalCompletions;
+
const completions = getGraphQLCompletions(
+
filename,
+
cursorPosition,
+
schema,
+
info
+
);
-
let node = findNode(source, cursorPosition);
-
if (!node) return originalCompletions;
-
-
while (
-
isNoSubstitutionTemplateLiteral(node) ||
-
isToken(node) ||
-
isTemplateExpression(node)
-
) {
-
node = node.parent;
-
}
-
-
if (isTaggedTemplateExpression(node)) {
-
const { template, tag } = node;
-
if (!isIdentifier(tag) || tag.text !== tagTemplate)
-
return originalCompletions;
-
-
const text = resolveTemplate(node, filename, info);
-
const foundToken = getToken(template, cursorPosition);
-
-
if (!foundToken || !schema.current) return originalCompletions;
-
-
let fragments: Array<FragmentDefinitionNode> = [];
-
try {
-
const parsed = parse(text);
-
fragments = parsed.definitions.filter(
-
x => x.kind === Kind.FRAGMENT_DEFINITION
-
) as Array<FragmentDefinitionNode>;
-
} catch (e) {}
-
-
const cursor = new Cursor(foundToken.line, foundToken.start);
-
const suggestions = getAutocompleteSuggestions(
-
schema.current,
-
text,
-
cursor
-
);
-
-
const token = getTokenAtPosition(text, cursor);
-
const spreadSuggestions = getSuggestionsForFragmentSpread(
-
token,
-
getTypeInfo(schema.current, token.state),
-
schema.current,
-
text,
-
fragments
-
);
-
-
const result: ts.WithMetadata<ts.CompletionInfo> = {
-
isGlobalCompletion: false,
-
isMemberCompletion: false,
-
isNewIdentifierLocation: false,
-
entries: [
-
...suggestions.map(suggestion => ({
-
...suggestion,
-
kind: ScriptElementKind.variableElement,
-
name: suggestion.label,
-
kindModifiers: 'declare',
-
sortText: suggestion.sortText || '0',
-
labelDetails: {
-
detail: suggestion.type
-
? ' ' + suggestion.type?.toString()
-
: undefined,
-
description: suggestion.documentation,
-
},
-
})),
-
...spreadSuggestions.map(suggestion => ({
-
...suggestion,
-
kind: ScriptElementKind.variableElement,
-
name: suggestion.label,
-
insertText: '...' + suggestion.label,
-
kindModifiers: 'declare',
-
sortText: '0',
-
labelDetails: {
-
description: suggestion.documentation,
-
},
-
})),
-
...originalCompletions.entries,
-
],
+
if (completions) {
+
return {
+
...completions,
+
entries: [...completions.entries, ...originalCompletions.entries],
};
-
return result;
} else {
return originalCompletions;
}
···
cursorPosition
);
-
const source = getSource(info, filename);
-
if (!source) return originalInfo;
-
-
let node = findNode(source, cursorPosition);
-
if (!node) return originalInfo;
-
-
while (
-
isNoSubstitutionTemplateLiteral(node) ||
-
isToken(node) ||
-
isTemplateExpression(node)
-
) {
-
node = node.parent;
-
}
-
-
if (isTaggedTemplateExpression(node)) {
-
const { template, tag } = node;
-
if (!isIdentifier(tag) || tag.text !== tagTemplate) return originalInfo;
-
-
const text = resolveTemplate(node, filename, info);
-
const foundToken = getToken(template, cursorPosition);
-
-
if (!foundToken || !schema.current) return originalInfo;
+
const quickInfo = getGraphQLQuickInfo(
+
filename,
+
cursorPosition,
+
schema,
+
info
+
);
-
const hoverInfo = getHoverInformation(
-
schema.current,
-
text,
-
new Cursor(foundToken.line, foundToken.start)
-
);
-
const result: ts.QuickInfo = {
-
kind: ts.ScriptElementKind.string,
-
textSpan: {
-
start: cursorPosition,
-
length: 1,
-
},
-
kindModifiers: '',
-
displayParts: Array.isArray(hoverInfo)
-
? hoverInfo.map(item => ({ kind: '', text: item as string }))
-
: [{ kind: '', text: hoverInfo as string }],
-
};
-
-
return result;
-
} else {
-
return originalInfo;
-
}
+
return quickInfo || originalInfo;
};
logger('proxy: ' + JSON.stringify(proxy));
+55
packages/graphqlsp/src/quickInfo.ts
···
+
import ts from 'typescript/lib/tsserverlibrary';
+
import { isIdentifier, isTaggedTemplateExpression } from 'typescript';
+
import { getHoverInformation } from 'graphql-language-service';
+
import { GraphQLSchema } from 'graphql';
+
+
import { bubbleUpTemplate, findNode, getSource } from './ast';
+
import { resolveTemplate } from './ast/resolve';
+
import { getToken } from './ast/token';
+
import { Cursor } from './ast/cursor';
+
+
export function getGraphQLQuickInfo(
+
filename: string,
+
cursorPosition: number,
+
schema: { current: GraphQLSchema | null },
+
info: ts.server.PluginCreateInfo
+
): ts.QuickInfo | undefined {
+
const tagTemplate = info.config.template || 'gql';
+
+
const source = getSource(info, filename);
+
if (!source) return undefined;
+
+
let node = findNode(source, cursorPosition);
+
if (!node) return undefined;
+
+
node = bubbleUpTemplate(node);
+
+
if (isTaggedTemplateExpression(node)) {
+
const { template, tag } = node;
+
if (!isIdentifier(tag) || tag.text !== tagTemplate) return undefined;
+
+
const foundToken = getToken(template, cursorPosition);
+
+
if (!foundToken || !schema.current) return undefined;
+
+
const hoverInfo = getHoverInformation(
+
schema.current,
+
resolveTemplate(node, filename, info),
+
new Cursor(foundToken.line, foundToken.start)
+
);
+
+
return {
+
kind: ts.ScriptElementKind.string,
+
textSpan: {
+
start: cursorPosition,
+
length: 1,
+
},
+
kindModifiers: '',
+
displayParts: Array.isArray(hoverInfo)
+
? hoverInfo.map(item => ({ kind: '', text: item as string }))
+
: [{ kind: '', text: hoverInfo as string }],
+
};
+
} else {
+
return undefined;
+
}
+
}
+2 -2
packages/graphqlsp/src/resolve.ts packages/graphqlsp/src/ast/resolve.ts
···
TaggedTemplateExpression,
} from 'typescript';
import ts from 'typescript/lib/tsserverlibrary';
-
import { findNode } from './ast';
-
import { getSource } from './utils';
+
import { findNode } from '.';
+
import { getSource } from '../ast';
export function resolveTemplate(
node: TaggedTemplateExpression,
packages/graphqlsp/src/token.ts packages/graphqlsp/src/ast/token.ts
packages/graphqlsp/src/types/generate.ts packages/graphqlsp/src/graphql/generateTypes.ts
+2 -21
packages/graphqlsp/src/utils.ts packages/graphqlsp/src/graphql/getFragmentSpreadSuggestions.ts
···
-
import ts from 'typescript/lib/tsserverlibrary';
-
import fs from 'fs';
import {
CompletionItem,
CompletionItemKind,
···
isCompositeType,
} from 'graphql';
-
export function isFileDirty(fileName: string, source: ts.SourceFile) {
-
const contents = fs.readFileSync(fileName, 'utf-8');
-
const currentText = source.getFullText();
-
-
return currentText !== contents;
-
}
-
-
export function getSource(info: ts.server.PluginCreateInfo, filename: string) {
-
const program = info.languageService.getProgram();
-
if (!program) return undefined;
-
-
const source = program.getSourceFile(filename);
-
if (!source) return undefined;
-
-
return source;
-
}
-
/**
* This part is vendored from https://github.com/graphql/graphiql/blob/main/packages/graphql-language-service/src/interface/autocompleteUtils.ts#L97
*/
-
export type CompletionItemBase = {
+
type CompletionItemBase = {
label: string;
isDeprecated?: boolean;
};
// Create the expected hint response given a possible list and a token
-
export function hintList<T extends CompletionItemBase>(
+
function hintList<T extends CompletionItemBase>(
token: ContextTokenUnion,
list: Array<T>
): Array<T> {
+11
test/e2e/fixture-project/fixtures/Post.ts
···
+
import { gql } from "@urql/core";
+
+
export const PostFields = gql`
+
fragment postFields on Post {
+
title
+
}
+
`
+
+
export const Post = (post: any) => {
+
return post.title
+
}
+12
test/e2e/fixture-project/fixtures/Posts.ts
···
+
import { gql } from "@urql/core";
+
import { Post } from "./Post";
+
+
const PostsQuery = gql`
+
query PostsList {
+
posts {
+
id
+
}
+
}
+
`
+
+
Post({ title: '' })
+133
test/e2e/fragments.test.ts
···
+
import { expect, afterAll, beforeAll, it, describe } from 'vitest';
+
import { TSServer } from './server';
+
import path from 'node:path';
+
import fs from 'node:fs';
+
import url from 'node:url';
+
import ts from 'typescript/lib/tsserverlibrary';
+
import { waitForExpect } from './util';
+
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+
+
const projectPath = path.resolve(__dirname, 'fixture-project');
+
describe('Fragments', () => {
+
const outFilePost = path.join(projectPath, 'Post.ts');
+
const outFilePosts = path.join(projectPath, 'Posts.ts');
+
const genFilePost = path.join(projectPath, 'Post.generated.ts');
+
const genFilePosts = path.join(projectPath, 'Posts.generated.ts');
+
const baseGenFile = path.join(projectPath, '__generated__/baseGraphQLSP.ts');
+
+
let server: TSServer;
+
beforeAll(async () => {
+
server = new TSServer(projectPath, { debugLog: false });
+
});
+
+
afterAll(() => {
+
try {
+
fs.unlinkSync(outFilePost);
+
fs.unlinkSync(outFilePosts);
+
fs.unlinkSync(genFilePost);
+
fs.unlinkSync(genFilePosts);
+
fs.unlinkSync(baseGenFile);
+
} catch {}
+
});
+
+
it('should send a message for missing fragment import', async () => {
+
server.sendCommand('open', {
+
file: outFilePost,
+
fileContent: '// empty',
+
scriptKindName: 'TS',
+
} satisfies ts.server.protocol.OpenRequestArgs);
+
+
server.sendCommand('open', {
+
file: outFilePosts,
+
fileContent: '// empty',
+
scriptKindName: 'TS',
+
} satisfies ts.server.protocol.OpenRequestArgs);
+
+
server.sendCommand('updateOpen', {
+
openFiles: [
+
{
+
file: outFilePosts,
+
fileContent: fs.readFileSync(
+
path.join(projectPath, 'fixtures/Posts.ts'),
+
'utf-8'
+
),
+
},
+
{
+
file: outFilePost,
+
fileContent: fs.readFileSync(
+
path.join(projectPath, 'fixtures/Post.ts'),
+
'utf-8'
+
),
+
},
+
],
+
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
+
+
server.sendCommand('saveto', {
+
file: outFilePost,
+
tmpfile: outFilePost,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
+
+
server.sendCommand('saveto', {
+
file: outFilePosts,
+
tmpfile: outFilePosts,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
+
+
await waitForExpect(() => {
+
expect(fs.readFileSync(outFilePosts, 'utf-8')).toContain(
+
`as typeof import('./Posts.generated').PostsListDocument`
+
);
+
});
+
+
await waitForExpect(() => {
+
const generatedPostsFileContents = fs.readFileSync(genFilePosts, 'utf-8');
+
expect(generatedPostsFileContents).toContain(
+
'export const PostsListDocument = '
+
);
+
expect(generatedPostsFileContents).toContain(
+
'import * as Types from "./__generated__/baseGraphQLSP"'
+
);
+
});
+
+
await waitForExpect(() => {
+
expect(fs.readFileSync(outFilePost, 'utf-8')).toContain(
+
`as typeof import('./Post.generated').PostFieldsFragmentDoc`
+
);
+
});
+
+
await waitForExpect(() => {
+
const generatedPostFileContents = fs.readFileSync(genFilePost, 'utf-8');
+
expect(generatedPostFileContents).toContain(
+
'export const PostFieldsFragmentDoc = '
+
);
+
expect(generatedPostFileContents).toContain(
+
'import * as Types from "./__generated__/baseGraphQLSP"'
+
);
+
});
+
+
const res = server.responses
+
.reverse()
+
.find(
+
resp =>
+
resp.type === 'event' &&
+
resp.event === 'semanticDiag' &&
+
resp.body.file === outFilePosts
+
);
+
+
expect(res?.body.diagnostics).toEqual([
+
{
+
category: 'message',
+
code: 51001,
+
end: {
+
line: 2,
+
offset: 31,
+
},
+
start: {
+
line: 2,
+
offset: 1,
+
},
+
text: 'Missing Fragment import(s) \'PostFields\' from "./Post".',
+
},
+
]);
+
}, 30000);
+
});
-19
test/e2e/graphqlsp.test.ts
···
server.close();
});
-
it('Generates types for a given query', async () => {
-
expect(() => {
-
fs.lstatSync(testFile);
-
fs.lstatSync(generatedFile);
-
fs.lstatSync(baseGeneratedFile);
-
}).not.toThrow();
-
-
const testFileContents = fs.readFileSync(testFile, 'utf-8');
-
const generatedFileContents = fs.readFileSync(generatedFile, 'utf-8');
-
-
expect(testFileContents).toContain(
-
`as typeof import('./simple.generated').AllPostsDocument`
-
);
-
expect(generatedFileContents).toContain('export const AllPostsDocument = ');
-
expect(generatedFileContents).toContain(
-
'import * as Types from "./__generated__/baseGraphQLSP"'
-
);
-
}, 7500);
-
it('Proposes suggestions for a selection-set', async () => {
server.send({
seq: 8,
+15 -2
test/e2e/rename.test.ts test/e2e/generate-types.test.ts
···
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const projectPath = path.resolve(__dirname, 'fixture-project');
-
describe('Operation name', () => {
+
describe('Type-generation', () => {
const outFile = path.join(projectPath, 'rename.ts');
const genFile = path.join(projectPath, 'rename.generated.ts');
+
const baseGenFile = path.join(projectPath, '__generated__/baseGraphQLSP.ts');
let server: TSServer;
beforeAll(async () => {
···
try {
fs.unlinkSync(outFile);
fs.unlinkSync(genFile);
+
fs.unlinkSync(baseGenFile);
} catch {}
});
···
await waitForExpect(() => {
expect(fs.readFileSync(outFile, 'utf-8')).toContain(
`as typeof import('./rename.generated').PostsDocument`
+
);
+
const generatedFileContents = fs.readFileSync(genFile, 'utf-8');
+
expect(generatedFileContents).toContain('export const PostsDocument = ');
+
expect(generatedFileContents).toContain(
+
'import * as Types from "./__generated__/baseGraphQLSP"'
);
});
+
expect(() => {
+
fs.lstatSync(outFile);
+
fs.lstatSync(genFile);
+
fs.lstatSync(baseGenFile);
+
}).not.toThrow();
+
server.sendCommand('updateOpen', {
openFiles: [
{
···
'export const PostListDocument ='
);
});
-
}, 12500);
+
}, 20000);
});