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

chore(perf): add caching for diagnostics (#104)

Changed files
+266 -201
.changeset
packages
example
src
graphqlsp
+5
.changeset/moody-clocks-approve.md
···
+
---
+
'@0no-co/graphqlsp': patch
+
---
+
+
add caching for gql-diagnostics
+8
packages/example/src/index.ts
···
.then(result => {
result.data?.pokemon;
});
+
+
const myQuery = gql`
+
query PokemonsAreAwesome {
+
pokemons {
+
id
+
}
+
}
+
`;
+2
packages/graphqlsp/package.json
···
"@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-operations": "^4.0.1",
"@graphql-typed-document-node/core": "^3.2.0",
+
"@sindresorhus/fnv1a": "^2.0.0",
"graphql-language-service": "^5.1.7",
+
"lru-cache": "^10.0.1",
"node-fetch": "^2.0.0"
},
"publishConfig": {
+219 -199
packages/graphqlsp/src/diagnostics.ts
···
OperationDefinitionNode,
parse,
} from 'graphql';
+
import { LRUCache } from 'lru-cache';
+
import fnv1a from '@sindresorhus/fnv1a';
import {
findAllImports,
···
let isGeneratingTypes = false;
+
const cache = new LRUCache<number, ts.Diagnostic[]>({
+
// how long to live in ms
+
ttl: 1000 * 60 * 15,
+
max: 5000,
+
});
+
export function getGraphQLDiagnostics(
// This is so that we don't change offsets when there are
// TypeScript errors
hasTSErrors: Boolean,
filename: string,
baseTypesPath: string,
-
schema: { current: GraphQLSchema | null },
+
schema: { current: GraphQLSchema | null; version: number },
info: ts.server.PluginCreateInfo
): ts.Diagnostic[] | undefined {
const logger = (msg: string) =>
···
return resolveTemplate(node, filename, info).combinedText;
});
-
const diagnostics = nodes
-
.map(originalNode => {
-
let node = originalNode;
-
if (isNoSubstitutionTemplateLiteral(node) || isTemplateExpression(node)) {
-
if (isTaggedTemplateExpression(node.parent)) {
-
node = node.parent;
-
} else {
-
return undefined;
+
let tsDiagnostics: ts.Diagnostic[] = [];
+
const cacheKey = fnv1a(texts.join('-') + schema.version);
+
if (cache.has(cacheKey)) {
+
tsDiagnostics = cache.get(cacheKey)!;
+
} else {
+
const diagnostics = nodes
+
.map(originalNode => {
+
let node = originalNode;
+
if (
+
isNoSubstitutionTemplateLiteral(node) ||
+
isTemplateExpression(node)
+
) {
+
if (isTaggedTemplateExpression(node.parent)) {
+
node = node.parent;
+
} else {
+
return undefined;
+
}
}
-
}
-
const { combinedText: text, resolvedSpans } = resolveTemplate(
-
node,
-
filename,
-
info
-
);
-
const lines = text.split('\n');
+
const { combinedText: text, resolvedSpans } = resolveTemplate(
+
node,
+
filename,
+
info
+
);
+
const lines = text.split('\n');
-
let isExpression = false;
-
if (isAsExpression(node.parent)) {
-
if (isExpressionStatement(node.parent.parent)) {
-
isExpression = true;
+
let isExpression = false;
+
if (isAsExpression(node.parent)) {
+
if (isExpressionStatement(node.parent.parent)) {
+
isExpression = true;
+
}
+
} else {
+
if (isExpressionStatement(node.parent)) {
+
isExpression = true;
+
}
}
-
} else {
-
if (isExpressionStatement(node.parent)) {
-
isExpression = true;
-
}
-
}
-
// When we are dealing with a plain gql statement we have to add two these can be recognised
-
// by the fact that the parent is an expressionStatement
-
let startingPosition =
-
node.pos + (tagTemplate.length + (isExpression ? 2 : 1));
-
const endPosition = startingPosition + node.getText().length;
-
const graphQLDiagnostics = getDiagnostics(text, schema.current)
-
.map(x => {
-
const { start, end } = x.range;
+
// When we are dealing with a plain gql statement we have to add two these can be recognised
+
// by the fact that the parent is an expressionStatement
+
let startingPosition =
+
node.pos + (tagTemplate.length + (isExpression ? 2 : 1));
+
const endPosition = startingPosition + node.getText().length;
+
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;
-
}
+
// 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;
-
}
+
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;
+
}
-
const locatedInFragment = resolvedSpans.find(x => {
-
const newEnd = x.new.start + x.new.length;
-
return startChar >= x.new.start && endChar <= newEnd;
-
});
+
const locatedInFragment = resolvedSpans.find(x => {
+
const newEnd = x.new.start + x.new.length;
+
return startChar >= x.new.start && endChar <= newEnd;
+
});
-
if (!!locatedInFragment) {
-
return {
-
...x,
-
start: locatedInFragment.original.start,
-
length: locatedInFragment.original.length,
-
};
-
} else {
-
if (startChar > endPosition) {
-
// we have to calculate the added length and fix this
-
const addedCharacters = resolvedSpans
-
.filter(x => x.new.start + x.new.length < startChar)
-
.reduce(
-
(acc, span) => acc + (span.new.length - span.original.length),
-
0
-
);
-
startChar = startChar - addedCharacters;
-
endChar = endChar - addedCharacters;
+
if (!!locatedInFragment) {
return {
...x,
-
start: startChar + 1,
-
length: endChar - startChar,
+
start: locatedInFragment.original.start,
+
length: locatedInFragment.original.length,
};
} else {
-
return {
-
...x,
-
start: startChar + 1,
-
length: endChar - startChar,
-
};
+
if (startChar > endPosition) {
+
// we have to calculate the added length and fix this
+
const addedCharacters = resolvedSpans
+
.filter(x => x.new.start + x.new.length < startChar)
+
.reduce(
+
(acc, span) =>
+
acc + (span.new.length - span.original.length),
+
0
+
);
+
startChar = startChar - addedCharacters;
+
endChar = endChar - addedCharacters;
+
return {
+
...x,
+
start: startChar + 1,
+
length: endChar - startChar,
+
};
+
} else {
+
return {
+
...x,
+
start: startChar + 1,
+
length: endChar - startChar,
+
};
+
}
}
-
}
-
})
-
.filter(x => x.start + x.length <= endPosition);
+
})
+
.filter(x => x.start + x.length <= endPosition);
-
try {
-
const parsed = parse(text, { noLocation: true });
+
try {
+
const parsed = parse(text, { noLocation: true });
-
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,
-
code: MISSING_OPERATION_NAME_CODE,
-
length: originalNode.getText().length,
-
range: {} as any,
-
severity: 2,
-
} as any);
+
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,
+
code: MISSING_OPERATION_NAME_CODE,
+
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 }>;
+
} catch (e) {}
-
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:
-
typeof diag.code === 'number'
-
? diag.code
-
: diag.severity === 2
-
? USING_DEPRECATED_FIELD_CODE
-
: SEMANTIC_DIAGNOSTIC_CODE,
-
messageText: diag.message.split('\n')[0],
-
}));
+
return graphQLDiagnostics;
+
})
+
.flat()
+
.filter(Boolean) as Array<Diagnostic & { length: number; start: number }>;
-
const imports = findAllImports(source);
-
if (imports.length && shouldCheckForColocatedFragments) {
-
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
-
imports.forEach(imp => {
-
if (!imp.importClause) return;
+
tsDiagnostics = diagnostics.map(diag => ({
+
file: source,
+
length: diag.length,
+
start: diag.start,
+
category:
+
diag.severity === 2
+
? ts.DiagnosticCategory.Warning
+
: ts.DiagnosticCategory.Error,
+
code:
+
typeof diag.code === 'number'
+
? diag.code
+
: diag.severity === 2
+
? USING_DEPRECATED_FIELD_CODE
+
: SEMANTIC_DIAGNOSTIC_CODE,
+
messageText: diag.message.split('\n')[0],
+
}));
-
const importedNames: string[] = [];
-
if (imp.importClause.name) {
-
importedNames.push(imp.importClause?.name.text);
-
}
+
const imports = findAllImports(source);
+
if (imports.length && shouldCheckForColocatedFragments) {
+
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
+
imports.forEach(imp => {
+
if (!imp.importClause) return;
-
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 importedNames: string[] = [];
+
if (imp.importClause.name) {
+
importedNames.push(imp.importClause?.name.text);
+
}
-
const symbol = typeChecker?.getSymbolAtLocation(imp.moduleSpecifier);
-
if (!symbol) return;
+
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 moduleExports = typeChecker?.getExportsOfModule(symbol);
-
if (!moduleExports) return;
+
const symbol = typeChecker?.getSymbolAtLocation(imp.moduleSpecifier);
+
if (!symbol) return;
-
const missingImports = moduleExports
-
.map(exp => {
-
if (importedNames.includes(exp.name)) {
-
return;
-
}
+
const moduleExports = typeChecker?.getExportsOfModule(symbol);
+
if (!moduleExports) return;
-
const declarations = exp.getDeclarations();
-
const declaration = declarations?.find(x => {
-
// TODO: check whether the sourceFile.fileName resembles the module
-
// specifier
-
return true;
-
});
+
const missingImports = moduleExports
+
.map(exp => {
+
if (importedNames.includes(exp.name)) {
+
return;
+
}
-
if (!declaration) return;
+
const declarations = exp.getDeclarations();
+
const declaration = declarations?.find(x => {
+
// TODO: check whether the sourceFile.fileName resembles the module
+
// specifier
+
return true;
+
});
-
const [template] = findAllTaggedTemplateNodes(declaration);
-
if (template) {
-
let node = template;
-
if (
-
isNoSubstitutionTemplateLiteral(node) ||
-
isTemplateExpression(node)
-
) {
-
if (isTaggedTemplateExpression(node.parent)) {
-
node = node.parent;
-
} else {
-
return;
-
}
-
}
+
if (!declaration) return;
-
const text = resolveTemplate(
-
node,
-
node.getSourceFile().fileName,
-
info
-
).combinedText;
-
try {
-
const parsed = parse(text, { noLocation: true });
+
const [template] = findAllTaggedTemplateNodes(declaration);
+
if (template) {
+
let node = template;
if (
-
parsed.definitions.every(
-
x => x.kind === Kind.FRAGMENT_DEFINITION
-
)
+
isNoSubstitutionTemplateLiteral(node) ||
+
isTemplateExpression(node)
) {
-
return `'${exp.name}'`;
+
if (isTaggedTemplateExpression(node.parent)) {
+
node = node.parent;
+
} else {
+
return;
+
}
}
-
} catch (e) {
-
return;
+
+
const text = resolveTemplate(
+
node,
+
node.getSourceFile().fileName,
+
info
+
).combinedText;
+
try {
+
const parsed = parse(text, { noLocation: true });
+
if (
+
parsed.definitions.every(
+
x => x.kind === Kind.FRAGMENT_DEFINITION
+
)
+
) {
+
return `'${exp.name}'`;
+
}
+
} catch (e) {
+
return;
+
}
}
-
}
-
})
-
.filter(Boolean);
+
})
+
.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: MISSING_FRAGMENT_CODE,
-
messageText: `Missing Fragment import(s) ${missingImports.join(
-
', '
-
)} from ${imp.moduleSpecifier.getText()}.`,
-
});
-
}
-
});
+
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: MISSING_FRAGMENT_CODE,
+
messageText: `Missing Fragment import(s) ${missingImports.join(
+
', '
+
)} from ${imp.moduleSpecifier.getText()}.`,
+
});
+
}
+
});
+
}
+
+
cache.set(cacheKey, tsDiagnostics);
}
if (
+10 -2
packages/graphqlsp/src/graphql/getSchema.ts
···
shouldTypegen: boolean,
scalars: Record<string, unknown>,
extraTypes?: string
-
): { current: GraphQLSchema | null } => {
-
const ref: { current: GraphQLSchema | null } = { current: null };
+
): { current: GraphQLSchema | null; version: number } => {
+
const ref: { current: GraphQLSchema | null; version: number } = {
+
current: null,
+
version: 0,
+
};
let url: URL | undefined;
let isJSON = false;
···
ref.current = buildClientSchema(
(result as { data: IntrospectionQuery }).data
);
+
ref.version = ref.version + 1;
logger(`Got schema for ${url!.toString()}`);
if (shouldTypegen)
generateBaseTypes(
···
ref.current = isJson
? buildClientSchema(JSON.parse(contents))
: buildSchema(contents);
+
ref.version = ref.version + 1;
+
if (shouldTypegen) generateBaseTypes(ref.current, baseTypesPath, scalars);
});
ref.current = isJson
? buildClientSchema(JSON.parse(contents))
: buildSchema(contents);
+
ref.version = ref.version + 1;
+
if (shouldTypegen)
generateBaseTypes(ref.current, baseTypesPath, scalars, extraTypes);
logger(`Got schema and initialized watcher for ${schema}`);
+22
pnpm-lock.yaml
···
'@graphql-typed-document-node/core':
specifier: ^3.2.0
version: 3.2.0(graphql@16.6.0)
+
'@sindresorhus/fnv1a':
+
specifier: ^2.0.0
+
version: 2.0.0
graphql-language-service:
specifier: ^5.1.7
version: 5.1.7(graphql@16.6.0)
+
lru-cache:
+
specifier: ^10.0.1
+
version: 10.0.1
node-fetch:
specifier: ^2.0.0
version: 2.6.7
···
rollup: 3.20.2
dev: true
+
/@sindresorhus/fnv1a@2.0.0:
+
resolution: {integrity: sha512-HAK3TQvR1AbJ4uMFBp9K0V+jlujsHkCokWJRebQK1mlXBG+i7Q5/Y7AKRmqVPt4T78Uqp2U5fFHgK3gxi4OUqw==}
+
engines: {node: '>=10'}
+
dev: false
+
+
/@sindresorhus/fnv1a@2.0.1:
+
resolution: {integrity: sha512-suq9tRQ6bkpMukTG5K5z0sPWB7t0zExMzZCdmYm6xTSSIm/yCKNm7VCL36wVeyTsFr597/UhU1OAYdHGMDiHrw==}
+
engines: {node: '>=10'}
+
dev: true
+
/@types/chai-subset@1.3.3:
resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==}
dependencies:
···
dependencies:
tslib: 2.5.0
+
/lru-cache@10.0.1:
+
resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==}
+
engines: {node: 14 || >=16.14}
+
/lru-cache@4.1.5:
resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==}
dependencies:
···
'@graphql-codegen/typescript': 4.0.1(graphql@16.6.0)
'@graphql-codegen/typescript-operations': 4.0.1(graphql@16.6.0)
'@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0)
+
'@sindresorhus/fnv1a': 2.0.1
graphql-language-service: 5.1.7(graphql@16.6.0)
+
lru-cache: 10.0.1
node-fetch: 2.6.7
transitivePeerDependencies:
- encoding