diff --git a/.changeset/lucky-friends-beg.md b/.changeset/lucky-friends-beg.md new file mode 100644 index 00000000..6df3ab0a --- /dev/null +++ b/.changeset/lucky-friends-beg.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': patch +--- + +Detect fragment usage in `maskFragments` calls to prevent false positive unused fragment warnings diff --git a/packages/graphqlsp/src/ast/checks.ts b/packages/graphqlsp/src/ast/checks.ts index 73014dc8..17f9e980 100644 --- a/packages/graphqlsp/src/ast/checks.ts +++ b/packages/graphqlsp/src/ast/checks.ts @@ -123,3 +123,13 @@ export const getSchemaName = ( } 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'; +}; diff --git a/packages/graphqlsp/src/ast/index.ts b/packages/graphqlsp/src/ast/index.ts index e6c8c9c8..68bbbf60 100644 --- a/packages/graphqlsp/src/ast/index.ts +++ b/packages/graphqlsp/src/ast/index.ts @@ -327,6 +327,21 @@ export function findAllImports( return sourceFile.statements.filter(ts.isImportDeclaration); } +export function findAllMaskFragmentsCalls( + sourceFile: ts.SourceFile +): Array { + const result: Array = []; + + 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 { while ( ts.isNoSubstitutionTemplateLiteral(node) || diff --git a/packages/graphqlsp/src/diagnostics.ts b/packages/graphqlsp/src/diagnostics.ts index 04e1c034..b86a633f 100644 --- a/packages/graphqlsp/src/diagnostics.ts +++ b/packages/graphqlsp/src/diagnostics.ts @@ -15,6 +15,7 @@ import { findAllCallExpressions, findAllPersistedCallExpressions, findAllTaggedTemplateNodes, + findAllMaskFragmentsCalls, getSource, unrollFragment, } from './ast'; @@ -292,6 +293,7 @@ export function getGraphQLDiagnostics( if (isCallExpression && shouldCheckForColocatedFragments) { const moduleSpecifierToFragments = getColocatedFragmentNames(source, info); + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); const usedFragments = new Set(); nodes.forEach(({ node }) => { @@ -307,6 +309,23 @@ export function getGraphQLDiagnostics( } 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, diff --git a/test/e2e/fixture-project-tada/fixtures/graphql.ts b/test/e2e/fixture-project-tada/fixtures/graphql.ts index 051974ad..1f57962c 100644 --- a/test/e2e/fixture-project-tada/fixtures/graphql.ts +++ b/test/e2e/fixture-project-tada/fixtures/graphql.ts @@ -6,4 +6,4 @@ export const graphql = initGraphQLTada<{ }>(); export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada'; -export { readFragment } from 'gql.tada'; +export { readFragment, maskFragments } from 'gql.tada'; diff --git a/test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts b/test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts new file mode 100644 index 00000000..11a92953 --- /dev/null +++ b/test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts @@ -0,0 +1,7 @@ +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); diff --git a/test/e2e/fixture-project-tada/graphql.ts b/test/e2e/fixture-project-tada/graphql.ts index 9d451c12..b15bb840 100644 --- a/test/e2e/fixture-project-tada/graphql.ts +++ b/test/e2e/fixture-project-tada/graphql.ts @@ -6,4 +6,4 @@ export const graphql = initGraphQLTada<{ }>(); export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada'; -export { readFragment } from 'gql.tada'; +export { readFragment, maskFragments } from 'gql.tada'; diff --git a/test/e2e/tada.test.ts b/test/e2e/tada.test.ts index 1130a565..482c7c7e 100644 --- a/test/e2e/tada.test.ts +++ b/test/e2e/tada.test.ts @@ -12,6 +12,10 @@ describe('Fragment + operations', () => { 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; @@ -38,6 +42,11 @@ describe('Fragment + operations', () => { fileContent: '// empty', scriptKindName: 'TS', } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfileUsedFragmentMask, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); server.sendCommand('updateOpen', { openFiles: [ @@ -69,6 +78,13 @@ describe('Fragment + operations', () => { 'utf-8' ), }, + { + file: outfileUsedFragmentMask, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/used-fragment-mask.ts'), + 'utf-8' + ), + }, ], } satisfies ts.server.protocol.UpdateOpenRequestArgs); @@ -88,11 +104,16 @@ describe('Fragment + operations', () => { 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); @@ -386,6 +407,29 @@ List out all Pokémon, optionally in pages` `); }, 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,