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

feat: initial support for `graphql.persisted` (#240)

Changed files
+422 -15
packages
example-tada
src
graphqlsp
test
e2e
fixture-project-tada
+3 -1
packages/example-tada/src/index.tsx
···
-
import { createClient, useQuery } from 'urql';
+
import { useQuery } from 'urql';
import { graphql } from './graphql';
import { Fields, Pokemon, PokemonFields } from './Pokemon';
···
}
}
`, [PokemonFields, Fields.Pokemon]);
+
+
const persisted = graphql.persisted<typeof PokemonQuery>("sha256:dc31ff9637bbc77bb95dffb2ca73b0e607639b018befd06e9ad801b54483d661")
const Pokemons = () => {
const [result] = useQuery({
+1
packages/graphqlsp/src/api.ts
···
export { getGraphQLDiagnostics } from './diagnostics';
export { init } from './ts';
+
export { findAllPersistedCallExpressions } from './ast';
+23
packages/graphqlsp/src/ast/index.ts
···
return { nodes: result, fragments };
}
+
export function findAllPersistedCallExpressions(
+
sourceFile: ts.SourceFile
+
): Array<ts.CallExpression> {
+
const result: Array<ts.CallExpression> = [];
+
function find(node: ts.Node) {
+
if (ts.isCallExpression(node)) {
+
// This expression ideally for us looks like <template>.persisted
+
const expression = node.expression.getText();
+
const parts = expression.split('.');
+
if (parts.length !== 2) return;
+
+
const [template, method] = parts;
+
if (!templates.has(template) || method !== 'persisted') return;
+
+
result.push(node);
+
} else {
+
ts.forEachChild(node, find);
+
}
+
}
+
find(sourceFile);
+
return result;
+
}
+
export function getAllFragments(
fileName: string,
node: ts.CallExpression,
+102
packages/graphqlsp/src/diagnostics.ts
···
import {
findAllCallExpressions,
+
findAllPersistedCallExpressions,
findAllTaggedTemplateNodes,
getSource,
} from './ast';
···
MISSING_FRAGMENT_CODE,
getColocatedFragmentNames,
} from './checkImports';
+
import { getDocumentReferenceFromTypeQuery } from './persisted';
const clientDirectives = new Set([
'populate',
···
export const SEMANTIC_DIAGNOSTIC_CODE = 52001;
export const MISSING_OPERATION_NAME_CODE = 52002;
export const USING_DEPRECATED_FIELD_CODE = 52004;
+
export const MISSING_PERSISTED_TYPE_ARG = 520100;
+
export const MISSING_PERSISTED_CODE_ARG = 520101;
+
export const MISSING_PERSISTED_DOCUMENT = 520102;
export const ALL_DIAGNOSTICS = [
SEMANTIC_DIAGNOSTIC_CODE,
MISSING_OPERATION_NAME_CODE,
USING_DEPRECATED_FIELD_CODE,
MISSING_FRAGMENT_CODE,
UNUSED_FIELD_CODE,
+
MISSING_PERSISTED_TYPE_ARG,
+
MISSING_PERSISTED_CODE_ARG,
+
MISSING_PERSISTED_DOCUMENT,
];
const cache = new LRUCache<number, ts.Diagnostic[]>({
···
const shouldCheckForColocatedFragments =
info.config.shouldCheckForColocatedFragments ?? true;
let fragmentDiagnostics: ts.Diagnostic[] = [];
+
+
if (isCallExpression) {
+
const persistedCalls = findAllPersistedCallExpressions(source);
+
// We need to check whether the user has correctly inserted a hash,
+
// by means of providing an argument to the function and that they
+
// are establishing a reference to the document by means of the generic.
+
//
+
// OPTIONAL: we could also check whether the hash is out of date with the
+
// document but this removes support for self-generating identifiers
+
const persistedDiagnostics = persistedCalls
+
.map<ts.Diagnostic | null>(callExpression => {
+
if (!callExpression.typeArguments) {
+
return {
+
category: ts.DiagnosticCategory.Warning,
+
code: MISSING_PERSISTED_TYPE_ARG,
+
file: source,
+
messageText: 'Missing generic pointing at the GraphQL document.',
+
start: callExpression.getStart(),
+
length: callExpression.getEnd() - callExpression.getStart(),
+
};
+
}
+
+
const [typeQuery] = callExpression.typeArguments;
+
+
if (!ts.isTypeQueryNode(typeQuery)) {
+
// Provide diagnostic about wroong generic
+
return {
+
category: ts.DiagnosticCategory.Warning,
+
code: MISSING_PERSISTED_TYPE_ARG,
+
file: source,
+
messageText:
+
'Provided generic should be a typeQueryNode in the shape of graphql.persisted<typeof document>.',
+
start: typeQuery.getStart(),
+
length: typeQuery.getEnd() - typeQuery.getStart(),
+
};
+
}
+
+
const { node: foundNode } = getDocumentReferenceFromTypeQuery(
+
typeQuery,
+
filename,
+
info
+
);
+
+
if (!foundNode) {
+
return {
+
category: ts.DiagnosticCategory.Warning,
+
code: MISSING_PERSISTED_DOCUMENT,
+
file: source,
+
messageText: `Can't find reference to "${typeQuery.getText()}".`,
+
start: typeQuery.getStart(),
+
length: typeQuery.getEnd() - typeQuery.getStart(),
+
};
+
}
+
+
const initializer = foundNode.initializer;
+
if (
+
!initializer ||
+
!ts.isCallExpression(initializer) ||
+
!ts.isNoSubstitutionTemplateLiteral(initializer.arguments[0])
+
) {
+
// TODO: we can make this check more stringent where we also parse and resolve
+
// the accompanying template.
+
return {
+
category: ts.DiagnosticCategory.Warning,
+
code: MISSING_PERSISTED_DOCUMENT,
+
file: source,
+
messageText: `Referenced type "${typeQuery.getText()}" is not a GraphQL document.`,
+
start: typeQuery.getStart(),
+
length: typeQuery.getEnd() - typeQuery.getStart(),
+
};
+
}
+
+
if (!callExpression.arguments[0]) {
+
// TODO: this might be covered by the API enforcing the first
+
// argument so can possibly be removed.
+
return {
+
category: ts.DiagnosticCategory.Warning,
+
code: MISSING_PERSISTED_CODE_ARG,
+
file: source,
+
messageText: `The call-expression is missing a hash for the persisted argument.`,
+
start: callExpression.arguments.pos,
+
length: callExpression.arguments.end - callExpression.arguments.pos,
+
};
+
}
+
+
return null;
+
})
+
.filter(Boolean);
+
+
tsDiagnostics.push(...(persistedDiagnostics as ts.Diagnostic[]));
+
}
+
if (isCallExpression && shouldCheckForColocatedFragments) {
const moduleSpecifierToFragments = getColocatedFragmentNames(source, info);
···
nodes as ts.NoSubstitutionTemplateLiteral[],
info
) || [];
+
+
if (!usageDiagnostics) return tsDiagnostics
return [...tsDiagnostics, ...usageDiagnostics];
} else {
+84 -5
packages/graphqlsp/src/index.ts
···
import { getGraphQLQuickInfo } from './quickInfo';
import { ALL_DIAGNOSTICS, getGraphQLDiagnostics } from './diagnostics';
import { templates } from './ast/templates';
+
import { getPersistedCodeFixAtPosition } from './persisted';
function createBasicDecorator(info: ts.server.PluginCreateInfo) {
const proxy: ts.LanguageService = Object.create(null);
···
}
};
+
proxy.getEditsForRefactor = (
+
filename,
+
formatOptions,
+
positionOrRange,
+
refactorName,
+
actionName,
+
preferences,
+
interactive
+
) => {
+
const original = info.languageService.getEditsForRefactor(
+
filename,
+
formatOptions,
+
positionOrRange,
+
refactorName,
+
actionName,
+
preferences,
+
interactive
+
);
+
+
const codefix = getPersistedCodeFixAtPosition(
+
filename,
+
typeof positionOrRange === 'number'
+
? positionOrRange
+
: positionOrRange.pos,
+
info
+
);
+
if (!codefix) return original;
+
return {
+
edits: [
+
{
+
fileName: filename,
+
textChanges: [{ newText: codefix.replacement, span: codefix.span }],
+
},
+
],
+
};
+
};
+
+
proxy.getApplicableRefactors = (
+
filename,
+
positionOrRange,
+
preferences,
+
reason,
+
kind,
+
includeInteractive
+
) => {
+
const original = info.languageService.getApplicableRefactors(
+
filename,
+
positionOrRange,
+
preferences,
+
reason,
+
kind,
+
includeInteractive
+
);
+
+
const codefix = getPersistedCodeFixAtPosition(
+
filename,
+
typeof positionOrRange === 'number'
+
? positionOrRange
+
: positionOrRange.pos,
+
info
+
);
+
console.log('[GraphQLSP]', JSON.stringify(codefix));
+
if (codefix) {
+
return [
+
{
+
name: 'GraphQL',
+
description: 'Operations specific to gql.tada!',
+
actions: [
+
{
+
name: 'Insert document-id',
+
description:
+
'Generate a document-id for your persisted-operation, by default a SHA256 hash.',
+
},
+
],
+
inlineable: true,
+
},
+
...original,
+
];
+
} else {
+
return original;
+
}
+
};
+
proxy.getQuickInfoAtPosition = (filename: string, cursorPosition: number) => {
const quickInfo = getGraphQLQuickInfo(
filename,
···
cursorPosition
);
};
-
-
// TODO: check out the following hooks
-
// - getSuggestionDiagnostics, can suggest refactors
-
// - getCompletionEntryDetails, this can build on the auto-complete for more information
-
// - getCodeFixesAtPosition
logger('proxy: ' + JSON.stringify(proxy));
+204
packages/graphqlsp/src/persisted.ts
···
+
import { ts } from './ts';
+
+
import { createHash } from 'crypto';
+
+
import { findAllCallExpressions, findNode, getSource } from './ast';
+
import { resolveTemplate } from './ast/resolve';
+
import { templates } from './ast/templates';
+
import { parse, print, visit } from '@0no-co/graphql.web';
+
+
type PersistedAction = {
+
span: {
+
start: number;
+
length: number;
+
};
+
replacement: string;
+
};
+
+
const isPersistedCall = (expr: ts.LeftHandSideExpression) => {
+
const expressionText = expr.getText();
+
const [template, method] = expressionText.split('.');
+
return templates.has(template) && method === 'persisted';
+
};
+
+
export function getPersistedCodeFixAtPosition(
+
filename: string,
+
position: number,
+
info: ts.server.PluginCreateInfo
+
): PersistedAction | undefined {
+
const isCallExpression = info.config.templateIsCallExpression ?? true;
+
+
if (!isCallExpression) return undefined;
+
+
let source = getSource(info, filename);
+
if (!source) return undefined;
+
+
const node = findNode(source, position);
+
if (!node) return undefined;
+
+
let callExpression: ts.Node = node;
+
// We found a node and need to check where on the path we are
+
// we expect this to look a little bit like
+
// const persistedDoc = graphql.persisted<typeof x>()
+
// When we are on the left half of this statement we bubble down
+
// looking for the correct call-expression and on the right hand
+
// we bubble up.
+
if (ts.isVariableStatement(callExpression)) {
+
callExpression =
+
callExpression.declarationList.declarations.find(declaration => {
+
return (
+
ts.isVariableDeclaration(declaration) &&
+
declaration.initializer &&
+
ts.isCallExpression(declaration.initializer)
+
);
+
}) || node;
+
} else if (ts.isVariableDeclarationList(callExpression)) {
+
callExpression =
+
callExpression.declarations.find(declaration => {
+
return (
+
ts.isVariableDeclaration(declaration) &&
+
declaration.initializer &&
+
ts.isCallExpression(declaration.initializer)
+
);
+
}) || node;
+
} else if (
+
ts.isVariableDeclaration(callExpression) &&
+
callExpression.initializer &&
+
ts.isCallExpression(callExpression.initializer)
+
) {
+
callExpression = callExpression.initializer;
+
} else {
+
while (callExpression && !ts.isCallExpression(callExpression)) {
+
callExpression = callExpression.parent;
+
}
+
}
+
+
// We want to ensure that we found a call-expression and that it looks
+
// like "graphql.persisted", in a future iteration when the API surface
+
// is more defined we will need to use the ts.Symbol to support re-exporting
+
// this function by means of "export const peristed = graphql.persisted".
+
if (
+
!ts.isCallExpression(callExpression) ||
+
!isPersistedCall(callExpression.expression) ||
+
!callExpression.typeArguments
+
)
+
return undefined;
+
+
const [typeQuery] = callExpression.typeArguments;
+
+
if (!ts.isTypeQueryNode(typeQuery)) return undefined;
+
+
const { node: found, filename: foundFilename } =
+
getDocumentReferenceFromTypeQuery(typeQuery, filename, info);
+
+
if (!found) return undefined;
+
+
const initializer = found.initializer;
+
if (
+
!initializer ||
+
!ts.isCallExpression(initializer) ||
+
!ts.isNoSubstitutionTemplateLiteral(initializer.arguments[0])
+
)
+
return undefined;
+
+
const externalSource = getSource(info, foundFilename)!;
+
const { fragments } = findAllCallExpressions(externalSource, info);
+
+
const text = resolveTemplate(
+
initializer.arguments[0],
+
foundFilename,
+
info
+
).combinedText;
+
const parsed = parse(text);
+
const spreads = new Set();
+
visit(parsed, {
+
FragmentSpread: node => {
+
spreads.add(node.name.value);
+
},
+
});
+
+
let resolvedText = text;
+
[...spreads].forEach(spreadName => {
+
const fragmentDefinition = fragments.find(x => x.name.value === spreadName);
+
if (!fragmentDefinition) {
+
console.warn(
+
`[GraphQLSP] could not find fragment for spread ${spreadName}!`
+
);
+
return;
+
}
+
+
resolvedText = `${resolvedText}\n\n${print(fragmentDefinition)}`;
+
});
+
+
const hash = createHash('sha256').update(text).digest('hex');
+
+
const existingHash = callExpression.arguments[0];
+
// We assume for now that this is either undefined or an existing string literal
+
if (!existingHash) {
+
// We have no persisted-identifier yet, suggest adding in a new one
+
return {
+
span: {
+
start: callExpression.arguments.pos,
+
length: 1,
+
},
+
replacement: `"sha256:${hash}")`,
+
};
+
} else if (
+
ts.isStringLiteral(existingHash) &&
+
existingHash.getText() !== `"sha256:${hash}"`
+
) {
+
// We are out of sync, suggest replacing this with the updated hash
+
return {
+
span: {
+
start: existingHash.getStart(),
+
length: existingHash.end - existingHash.getStart(),
+
},
+
replacement: `"sha256:${hash}"`,
+
};
+
} else if (ts.isIdentifier(existingHash)) {
+
// Suggest replacing a reference with a static one
+
// this to make these easier to statically analyze
+
return {
+
span: {
+
start: existingHash.getStart(),
+
length: existingHash.end - existingHash.getStart(),
+
},
+
replacement: `"sha256:${hash}"`,
+
};
+
} else {
+
return undefined;
+
}
+
}
+
+
export const getDocumentReferenceFromTypeQuery = (
+
typeQuery: ts.TypeQueryNode,
+
filename: string,
+
info: ts.server.PluginCreateInfo
+
): { node: ts.VariableDeclaration | null; filename: string } => {
+
// We look for the references of the generic so that we can use the document
+
// to generate the hash.
+
const references = info.languageService.getReferencesAtPosition(
+
filename,
+
typeQuery.exprName.getStart()
+
);
+
+
if (!references) return { node: null, filename };
+
+
let found: ts.VariableDeclaration | null = null;
+
let foundFilename = filename;
+
references.forEach(ref => {
+
if (found) return;
+
+
const source = getSource(info, ref.fileName);
+
if (!source) return;
+
const foundNode = findNode(source, ref.textSpan.start);
+
if (!foundNode) return;
+
+
if (ts.isVariableDeclaration(foundNode.parent)) {
+
found = foundNode.parent;
+
foundFilename = ref.fileName;
+
}
+
});
+
+
return { node: found, filename: foundFilename };
+
};
+5 -5
pnpm-lock.yaml
···
peerDependencies:
rollup: ^2.14.0||^3.0.0||^4.0.0
tslib: '*'
-
typescript: '>=3.7.0'
+
typescript: ^5.3.3
peerDependenciesMeta:
rollup:
optional: true
···
resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
engines: {node: '>=14'}
peerDependencies:
-
typescript: '>=4.9.5'
+
typescript: ^5.3.3
peerDependenciesMeta:
typescript:
optional: true
···
engines: {node: '>=16'}
peerDependencies:
rollup: ^3.29.4 || ^4
-
typescript: ^4.5 || ^5.0
+
typescript: ^5.3.3
dependencies:
magic-string: 0.30.5
rollup: 4.9.5
···
'@swc/core': '>=1.2.50'
'@swc/wasm': '>=1.2.50'
'@types/node': '*'
-
typescript: '>=2.7'
+
typescript: ^5.3.3
peerDependenciesMeta:
'@swc/core':
optional: true
···
id: file:packages/graphqlsp
name: '@0no-co/graphqlsp'
peerDependencies:
-
typescript: ^5.0.0
+
typescript: ^5.3.3
dependencies:
'@gql.tada/internal': 0.1.2(graphql@16.8.1)(typescript@5.3.3)
graphql: 16.8.1
-4
test/e2e/fixture-project-tada/introspection.d.ts
···
{
"kind": "SCALAR",
"name": "Boolean"
-
},
-
{
-
"kind": "SCALAR",
-
"name": "Any"
}
],
"directives": []