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

Fix: detecting fragment usage in maskFragments() (#379)

Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>

yoshi 10b5aba7 2a165394

Changed files
+102 -2
.changeset
packages
graphqlsp
test
e2e
+5
.changeset/lucky-friends-beg.md
···
···
+
---
+
'@0no-co/graphqlsp': patch
+
---
+
+
Detect fragment usage in `maskFragments` calls to prevent false positive unused fragment warnings
+10
packages/graphqlsp/src/ast/checks.ts
···
}
return null;
};
···
}
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';
+
};
+15
packages/graphqlsp/src/ast/index.ts
···
return sourceFile.statements.filter(ts.isImportDeclaration);
}
export function bubbleUpTemplate(node: ts.Node): ts.Node {
while (
ts.isNoSubstitutionTemplateLiteral(node) ||
···
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 {
while (
ts.isNoSubstitutionTemplateLiteral(node) ||
+19
packages/graphqlsp/src/diagnostics.ts
···
findAllCallExpressions,
findAllPersistedCallExpressions,
findAllTaggedTemplateNodes,
getSource,
unrollFragment,
} from './ast';
···
if (isCallExpression && shouldCheckForColocatedFragments) {
const moduleSpecifierToFragments = getColocatedFragmentNames(source, info);
const usedFragments = new Set();
nodes.forEach(({ node }) => {
···
},
});
} catch (e) {}
});
Object.keys(moduleSpecifierToFragments).forEach(moduleSpecifier => {
···
findAllCallExpressions,
findAllPersistedCallExpressions,
findAllTaggedTemplateNodes,
+
findAllMaskFragmentsCalls,
getSource,
unrollFragment,
} from './ast';
···
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 => {
+1 -1
test/e2e/fixture-project-tada/fixtures/graphql.ts
···
}>();
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
-
export { readFragment } from 'gql.tada';
···
}>();
export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
+
export { readFragment, maskFragments } from 'gql.tada';
+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 type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
+
export { readFragment, maskFragments } from 'gql.tada';
+44
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 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);
···
'utf-8'
),
},
],
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
···
file: outfileUnusedFragment,
tmpfile: outfileUnusedFragment,
} satisfies ts.server.protocol.SavetoRequestArgs);
});
afterAll(() => {
try {
fs.unlinkSync(outfileUnusedFragment);
fs.unlinkSync(outfileCombinations);
fs.unlinkSync(outfileCombo);
fs.unlinkSync(outfileTypeCondition);
···
},
]
`);
}, 30000);
it('gives quick-info at start of word (#15)', async () => {
···
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 () => {