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

Compare changes

Choose any two refs to compare.

Changed files
+267 -11
.github
workflows
packages
test
+4 -1
.github/workflows/release.yaml
···
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
-
version: 9
+
version: 8.6.1
run_install: false
- name: Get pnpm store directory
···
- name: Install Dependencies
run: pnpm install --frozen-lockfile --prefer-offline
+
+
- name: Update npm
+
run: npm install -g npm@11.6.2
- name: PR or Publish
id: changesets
+10 -1
packages/example-tada/src/index.tsx
···
}
`, [PokemonFields, Fields.Pokemon])
-
const persisted = graphql.persisted<typeof PokemonQuery>("sha256:78c769ed6cfef67e17e579a2abfe4da27bd51e09ed832a88393148bcce4c5a7d")
+
const Test = graphql(`
+
query Po($id: ID!) {
+
pokemon(id: $id) {
+
id
+
fleeRate
+
...Pok
+
...pokemonFields
+
}
+
}
+
`, [])
const Pokemons = () => {
const [result] = useQuery({
+14
packages/graphqlsp/CHANGELOG.md
···
# @0no-co/graphqlsp
+
## 1.15.2
+
+
### Patch Changes
+
+
- Detect fragment usage in `maskFragments` calls to prevent false positive unused fragment warnings
+
Submitted by [@takumiyoshikawa](https://github.com/takumiyoshikawa) (See [#379](https://github.com/0no-co/GraphQLSP/pull/379))
+
+
## 1.15.1
+
+
### Patch Changes
+
+
- Correctly identify missing fragments for gql.tada graphql call-expressions
+
Submitted by [@JoviDeCroock](https://github.com/JoviDeCroock) (See [#376](https://github.com/0no-co/GraphQLSP/pull/376))
+
## 1.15.0
### Minor Changes
+1 -1
packages/graphqlsp/package.json
···
{
"name": "@0no-co/graphqlsp",
-
"version": "1.15.0",
+
"version": "1.15.2",
"description": "TypeScript LSP plugin that finds GraphQL documents in your code and provides hints and auto-generates types.",
"main": "./dist/graphqlsp",
"module": "./dist/graphqlsp.mjs",
+10
packages/graphqlsp/src/ast/checks.ts
···
}
return null;
};
+
+
/** Checks if node is a maskFragments() call */
+
export const isMaskFragmentsCall = (
+
node: ts.Node
+
): node is ts.CallExpression => {
+
if (!ts.isCallExpression(node)) return false;
+
if (!ts.isIdentifier(node.expression)) return false;
+
// Only checks function name, not whether it's from gql.tada
+
return node.expression.escapedText === 'maskFragments';
+
};
+34 -5
packages/graphqlsp/src/ast/index.ts
···
return checks.isGraphQLCall(value, checker) ? value : null;
}
-
function unrollFragment(
+
export function unrollFragment(
element: ts.Identifier,
info: ts.server.PluginCreateInfo,
checker: ts.TypeChecker | undefined
···
nodes: Array<{
node: ts.StringLiteralLike;
schema: string | null;
+
// For gql.tada call-expressions, this contains the identifiers of explicitly declared fragments
+
tadaFragmentRefs?: readonly ts.Identifier[] | null;
}>;
fragments: Array<FragmentDefinitionNode>;
} {
···
const result: Array<{
node: ts.StringLiteralLike;
schema: string | null;
+
tadaFragmentRefs?: readonly ts.Identifier[];
}> = [];
let fragments: Array<FragmentDefinitionNode> = [];
let hasTriedToFindFragments = shouldSearchFragments ? false : true;
···
const name = checks.getSchemaName(node, typeChecker);
const text = node.arguments[0];
const fragmentRefs = resolveTadaFragmentArray(node.arguments[1]);
+
const isTadaCall = checks.isTadaGraphQLCall(node, typeChecker);
if (!hasTriedToFindFragments && !fragmentRefs) {
-
hasTriedToFindFragments = true;
-
fragments.push(...getAllFragments(sourceFile.fileName, node, info));
+
// Only collect global fragments if this is NOT a gql.tada call
+
if (!isTadaCall) {
+
hasTriedToFindFragments = true;
+
fragments.push(...getAllFragments(node, info));
+
}
} else if (fragmentRefs) {
for (const identifier of fragmentRefs) {
fragments.push(...unrollFragment(identifier, info, typeChecker));
···
}
if (text && ts.isStringLiteralLike(text)) {
-
result.push({ node: text, schema: name });
+
result.push({
+
node: text,
+
schema: name,
+
tadaFragmentRefs: isTadaCall
+
? fragmentRefs === undefined
+
? []
+
: fragmentRefs
+
: undefined,
+
});
}
}
find(sourceFile);
···
}
export function getAllFragments(
-
fileName: string,
node: ts.Node,
info: ts.server.PluginCreateInfo
) {
···
sourceFile: ts.SourceFile
): Array<ts.ImportDeclaration> {
return sourceFile.statements.filter(ts.isImportDeclaration);
+
}
+
+
export function findAllMaskFragmentsCalls(
+
sourceFile: ts.SourceFile
+
): Array<ts.CallExpression> {
+
const result: Array<ts.CallExpression> = [];
+
+
function find(node: ts.Node): void {
+
if (checks.isMaskFragmentsCall(node)) {
+
result.push(node);
+
}
+
ts.forEachChild(node, find);
+
}
+
find(sourceFile);
+
return result;
}
export function bubbleUpTemplate(node: ts.Node): ts.Node {
+1 -1
packages/graphqlsp/src/autoComplete.ts
···
return undefined;
const queryText = node.arguments[0].getText().slice(1, -1);
-
const fragments = getAllFragments(filename, node, info);
+
const fragments = getAllFragments(node, info);
text = `${queryText}\n${fragments.map(x => print(x)).join('\n')}`;
cursor = new Cursor(foundToken.line, foundToken.start - 1);
+36
packages/graphqlsp/src/diagnostics.ts
···
findAllCallExpressions,
findAllPersistedCallExpressions,
findAllTaggedTemplateNodes,
+
findAllMaskFragmentsCalls,
getSource,
+
unrollFragment,
} from './ast';
import { resolveTemplate } from './ast/resolve';
import { UNUSED_FIELD_CODE, checkFieldUsageInFile } from './fieldUsage';
···
if (isCallExpression && shouldCheckForColocatedFragments) {
const moduleSpecifierToFragments = getColocatedFragmentNames(source, info);
+
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
const usedFragments = new Set();
nodes.forEach(({ node }) => {
···
} catch (e) {}
});
+
// check for maskFragments() calls
+
const maskFragmentsCalls = findAllMaskFragmentsCalls(source);
+
maskFragmentsCalls.forEach(call => {
+
const firstArg = call.arguments[0];
+
if (!firstArg) return;
+
+
// Handle array of fragments: maskFragments([Fragment1, Fragment2], data)
+
if (ts.isArrayLiteralExpression(firstArg)) {
+
firstArg.elements.forEach(element => {
+
if (ts.isIdentifier(element)) {
+
const fragmentDefs = unrollFragment(element, info, typeChecker);
+
fragmentDefs.forEach(def => usedFragments.add(def.name.value));
+
}
+
});
+
}
+
});
+
Object.keys(moduleSpecifierToFragments).forEach(moduleSpecifier => {
const {
fragments: fragmentNames,
···
nodes: {
node: ts.TaggedTemplateExpression | ts.StringLiteralLike;
schema: string | null;
+
tadaFragmentRefs?: readonly ts.Identifier[];
}[];
fragments: FragmentDefinitionNode[];
},
···
): ts.Diagnostic[] => {
const filename = source.fileName;
const isCallExpression = info.config.templateIsCallExpression ?? true;
+
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
const diagnostics = nodes
.map(originalNode => {
···
(isExpression ? 2 : 0));
const endPosition = startingPosition + node.getText().length;
let docFragments = [...fragments];
+
+
if (originalNode.tadaFragmentRefs !== undefined) {
+
const fragmentNames = new Set<string>();
+
for (const identifier of originalNode.tadaFragmentRefs) {
+
const unrolled = unrollFragment(identifier, info, typeChecker);
+
unrolled.forEach((frag: FragmentDefinitionNode) =>
+
fragmentNames.add(frag.name.value)
+
);
+
}
+
docFragments = docFragments.filter(frag =>
+
fragmentNames.has(frag.name.value)
+
);
+
}
+
if (isCallExpression) {
try {
const documentFragments = parse(text, {
+1 -1
test/e2e/fixture-project-tada/fixtures/graphql.ts
···
}>();
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
-
export { readFragment } from 'gql.tada';
+
export { readFragment, maskFragments } from 'gql.tada';
+33
test/e2e/fixture-project-tada/fixtures/missing-fragment-dep.ts
···
+
import { graphql } from './graphql';
+
+
const pokemonFragment = graphql(`
+
fragment PokemonBasicInfo on Pokemon {
+
id
+
name
+
}
+
`);
+
+
// This query correctly includes the fragment as a dep
+
const FirstQuery = graphql(
+
`
+
query FirstQuery {
+
pokemons(limit: 1) {
+
...PokemonBasicInfo
+
}
+
}
+
`,
+
[pokemonFragment]
+
);
+
+
// This query uses the fragment but DOES NOT include it as a dep
+
// It should show an error, but currently doesn't because the fragment
+
// was already added as a dep in FirstQuery above
+
const SecondQuery = graphql(`
+
query SecondQuery {
+
pokemons(limit: 2) {
+
...PokemonBasicInfo
+
}
+
}
+
`);
+
+
export { FirstQuery, SecondQuery };
+7
test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts
···
+
import { maskFragments } from './graphql';
+
import { Pokemon, PokemonFields } from './fragment';
+
+
const data = { id: '1', name: 'Pikachu', fleeRate: 0.1 };
+
const x = maskFragments([PokemonFields], data);
+
+
console.log(Pokemon);
+1 -1
test/e2e/fixture-project-tada/graphql.ts
···
}>();
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
-
export { readFragment } from 'gql.tada';
+
export { readFragment, maskFragments } from 'gql.tada';
+115
test/e2e/tada.test.ts
···
const outfileCombo = path.join(projectPath, 'simple.ts');
const outfileTypeCondition = path.join(projectPath, 'type-condition.ts');
const outfileUnusedFragment = path.join(projectPath, 'unused-fragment.ts');
+
const outfileUsedFragmentMask = path.join(
+
projectPath,
+
'used-fragment-mask.ts'
+
);
const outfileCombinations = path.join(projectPath, 'fragment.ts');
let server: TSServer;
···
} satisfies ts.server.protocol.OpenRequestArgs);
server.sendCommand('open', {
file: outfileUnusedFragment,
+
fileContent: '// empty',
+
scriptKindName: 'TS',
+
} satisfies ts.server.protocol.OpenRequestArgs);
+
server.sendCommand('open', {
+
file: outfileUsedFragmentMask,
fileContent: '// empty',
scriptKindName: 'TS',
} satisfies ts.server.protocol.OpenRequestArgs);
···
'utf-8'
),
},
+
{
+
file: outfileUsedFragmentMask,
+
fileContent: fs.readFileSync(
+
path.join(projectPath, 'fixtures/used-fragment-mask.ts'),
+
'utf-8'
+
),
+
},
],
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
···
file: outfileUnusedFragment,
tmpfile: outfileUnusedFragment,
} satisfies ts.server.protocol.SavetoRequestArgs);
+
server.sendCommand('saveto', {
+
file: outfileUsedFragmentMask,
+
tmpfile: outfileUsedFragmentMask,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
});
afterAll(() => {
try {
fs.unlinkSync(outfileUnusedFragment);
+
fs.unlinkSync(outfileUsedFragmentMask);
fs.unlinkSync(outfileCombinations);
fs.unlinkSync(outfileCombo);
fs.unlinkSync(outfileTypeCondition);
···
`);
}, 30000);
+
it('should not warn about unused fragments when using maskFragments', async () => {
+
server.sendCommand('saveto', {
+
file: outfileUsedFragmentMask,
+
tmpfile: outfileUsedFragmentMask,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
+
+
await server.waitForResponse(
+
e =>
+
e.type === 'event' &&
+
e.event === 'semanticDiag' &&
+
e.body?.file === outfileUsedFragmentMask
+
);
+
+
const res = server.responses.filter(
+
resp =>
+
resp.type === 'event' &&
+
resp.event === 'semanticDiag' &&
+
resp.body?.file === outfileUsedFragmentMask
+
);
+
// Should have no diagnostics about unused fragments since maskFragments uses them
+
expect(res[0].body.diagnostics).toMatchInlineSnapshot(`[]`);
+
}, 30000);
+
it('gives quick-info at start of word (#15)', async () => {
server.send({
seq: 11,
···
`);
}, 30000);
});
+
+
describe('Fragment dependencies - Issue #494', () => {
+
const projectPath = path.resolve(__dirname, 'fixture-project-tada');
+
const outfileMissingFragmentDep = path.join(
+
projectPath,
+
'missing-fragment-dep.ts'
+
);
+
+
let server: TSServer;
+
beforeAll(async () => {
+
server = new TSServer(projectPath, { debugLog: false });
+
+
server.sendCommand('open', {
+
file: outfileMissingFragmentDep,
+
fileContent: '// empty',
+
scriptKindName: 'TS',
+
} satisfies ts.server.protocol.OpenRequestArgs);
+
+
server.sendCommand('updateOpen', {
+
openFiles: [
+
{
+
file: outfileMissingFragmentDep,
+
fileContent: fs.readFileSync(
+
path.join(projectPath, 'fixtures/missing-fragment-dep.ts'),
+
'utf-8'
+
),
+
},
+
],
+
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
+
+
server.sendCommand('saveto', {
+
file: outfileMissingFragmentDep,
+
tmpfile: outfileMissingFragmentDep,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
+
});
+
+
afterAll(() => {
+
try {
+
fs.unlinkSync(outfileMissingFragmentDep);
+
} catch {}
+
});
+
+
it('warns about missing fragment dep even when fragment is used in another query in same file', async () => {
+
await server.waitForResponse(
+
e =>
+
e.type === 'event' &&
+
e.event === 'semanticDiag' &&
+
e.body?.file === outfileMissingFragmentDep
+
);
+
+
const res = server.responses.filter(
+
resp =>
+
resp.type === 'event' &&
+
resp.event === 'semanticDiag' &&
+
resp.body?.file === outfileMissingFragmentDep
+
);
+
+
// Should have a diagnostic about the unknown fragment in SecondQuery
+
expect(res.length).toBeGreaterThan(0);
+
expect(res[0].body.diagnostics.length).toBeGreaterThan(0);
+
+
const fragmentError = res[0].body.diagnostics.find((diag: any) =>
+
diag.text.includes('PokemonBasicInfo')
+
);
+
+
expect(fragmentError).toBeDefined();
+
expect(fragmentError.text).toBe('Unknown fragment "PokemonBasicInfo".');
+
expect(fragmentError.code).toBe(52001);
+
expect(fragmentError.category).toBe('error');
+
}, 30000);
+
});