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

Configure Feed

Select the types of activity you want to include in your feed.

feat: add missing fragment spread warnings (#152)

* warn for missing fragment spreads with client-preset

* add todo and bail logic

* add changeset

* optimise

* add test

* fixies

* fix suggestions

authored by

Jovi De Croock and committed by
GitHub
05da5894 7afa7461

+350 -138
+5
.changeset/mean-news-unite.md
··· 1 + --- 2 + '@0no-co/graphqlsp': minor 3 + --- 4 + 5 + Warn when an import defines a fragment that is unused in the current file
+1 -1
packages/example-external-generator/tsconfig.json
··· 5 5 "name": "@0no-co/graphqlsp", 6 6 "schema": "./schema.graphql", 7 7 "disableTypegen": true, 8 - "shouldCheckForColocatedFragments": false, 8 + "shouldCheckForColocatedFragments": true, 9 9 "template": "graphql", 10 10 "templateIsCallExpression": true, 11 11 "trackFieldUsage": true
+3 -2
packages/graphqlsp/src/ast/index.ts
··· 55 55 export function findAllCallExpressions( 56 56 sourceFile: ts.SourceFile, 57 57 template: string, 58 - info: ts.server.PluginCreateInfo 58 + info: ts.server.PluginCreateInfo, 59 + shouldSearchFragments: boolean = true 59 60 ): { 60 61 nodes: Array<ts.NoSubstitutionTemplateLiteral>; 61 62 fragments: Array<FragmentDefinitionNode>; 62 63 } { 63 64 const result: Array<ts.NoSubstitutionTemplateLiteral> = []; 64 65 let fragments: Array<FragmentDefinitionNode> = []; 65 - let hasTriedToFindFragments = false; 66 + let hasTriedToFindFragments = shouldSearchFragments ? false : true; 66 67 function find(node: ts.Node) { 67 68 if (ts.isCallExpression(node) && node.expression.getText() === template) { 68 69 if (!hasTriedToFindFragments) {
+7 -3
packages/graphqlsp/src/autoComplete.ts
··· 64 64 const foundToken = getToken(node.arguments[0], cursorPosition); 65 65 if (!schema.current || !foundToken) return undefined; 66 66 67 - const queryText = node.arguments[0].getText(); 67 + const queryText = node.arguments[0].getText().slice(1, -1); 68 68 const fragments = getAllFragments(filename, node, info); 69 69 70 - text = `${queryText}\m${fragments.map(x => print(x)).join('\n')}`; 70 + text = `${queryText}\n${fragments.map(x => print(x)).join('\n')}`; 71 71 cursor = new Cursor(foundToken.line, foundToken.start - 1); 72 72 } else if (ts.isTaggedTemplateExpression(node)) { 73 73 const { template, tag } = node; ··· 151 151 fragments = parsed.definitions.filter( 152 152 x => x.kind === Kind.FRAGMENT_DEFINITION 153 153 ) as Array<FragmentDefinitionNode>; 154 - } catch (e) {} 154 + } catch (e) { 155 + console.log('[GraphQLSP] ', e); 156 + } 157 + console.log('fraggers', fragments.map(x => print(x)).join('\n')); 155 158 156 159 let suggestions = getAutocompleteSuggestions(schema, queryText, cursor); 157 160 let spreadSuggestions = getSuggestionsForFragmentSpread( ··· 161 164 queryText, 162 165 fragments 163 166 ); 167 + console.log(JSON.stringify(spreadSuggestions, null, 2)); 164 168 165 169 const state = 166 170 token.state.kind === 'Invalid' ? token.state.prevState : token.state;
+166 -2
packages/graphqlsp/src/checkImports.ts
··· 1 1 import ts from 'typescript/lib/tsserverlibrary'; 2 - import { Kind, parse } from 'graphql'; 2 + import { FragmentDefinitionNode, Kind, parse } from 'graphql'; 3 3 4 - import { findAllImports, findAllTaggedTemplateNodes } from './ast'; 4 + import { 5 + findAllCallExpressions, 6 + findAllImports, 7 + findAllTaggedTemplateNodes, 8 + getSource, 9 + } from './ast'; 5 10 import { resolveTemplate } from './ast/resolve'; 6 11 7 12 export const MISSING_FRAGMENT_CODE = 52003; ··· 113 118 114 119 return tsDiagnostics; 115 120 }; 121 + 122 + export const getColocatedFragmentNames = ( 123 + source: ts.SourceFile, 124 + info: ts.server.PluginCreateInfo 125 + ): Record< 126 + string, 127 + { start: number; length: number; fragments: Array<string> } 128 + > => { 129 + const imports = findAllImports(source); 130 + const importSpecifierToFragments: Record< 131 + string, 132 + { start: number; length: number; fragments: Array<string> } 133 + > = {}; 134 + 135 + if (imports.length) { 136 + imports.forEach(imp => { 137 + if (!imp.importClause) return; 138 + 139 + if (imp.importClause.name) { 140 + const definitions = info.languageService.getDefinitionAtPosition( 141 + source.fileName, 142 + imp.importClause.name.getStart() 143 + ); 144 + if (definitions && definitions.length) { 145 + const [def] = definitions; 146 + if (def.fileName.includes('node_modules')) return; 147 + 148 + const externalSource = getSource(info, def.fileName); 149 + if (!externalSource) return; 150 + 151 + const fragmentsForImport = getFragmentsInSource(externalSource, info); 152 + 153 + const names = fragmentsForImport.map(fragment => fragment.name.value); 154 + if ( 155 + names.length && 156 + !importSpecifierToFragments[imp.moduleSpecifier.getText()] 157 + ) { 158 + importSpecifierToFragments[imp.moduleSpecifier.getText()] = { 159 + start: imp.moduleSpecifier.getStart(), 160 + length: imp.moduleSpecifier.getText().length, 161 + fragments: names, 162 + }; 163 + } else if (names.length) { 164 + importSpecifierToFragments[ 165 + imp.moduleSpecifier.getText() 166 + ].fragments = 167 + importSpecifierToFragments[ 168 + imp.moduleSpecifier.getText() 169 + ].fragments.concat(names); 170 + } 171 + } 172 + } 173 + 174 + if ( 175 + imp.importClause.namedBindings && 176 + ts.isNamespaceImport(imp.importClause.namedBindings) 177 + ) { 178 + const definitions = info.languageService.getDefinitionAtPosition( 179 + source.fileName, 180 + imp.importClause.namedBindings.getStart() 181 + ); 182 + if (definitions && definitions.length) { 183 + const [def] = definitions; 184 + if (def.fileName.includes('node_modules')) return; 185 + 186 + const externalSource = getSource(info, def.fileName); 187 + if (!externalSource) return; 188 + 189 + const fragmentsForImport = getFragmentsInSource(externalSource, info); 190 + const names = fragmentsForImport.map(fragment => fragment.name.value); 191 + if ( 192 + names.length && 193 + !importSpecifierToFragments[imp.moduleSpecifier.getText()] 194 + ) { 195 + importSpecifierToFragments[imp.moduleSpecifier.getText()] = { 196 + start: imp.moduleSpecifier.getStart(), 197 + length: imp.moduleSpecifier.getText().length, 198 + fragments: names, 199 + }; 200 + } else if (names.length) { 201 + importSpecifierToFragments[ 202 + imp.moduleSpecifier.getText() 203 + ].fragments = 204 + importSpecifierToFragments[ 205 + imp.moduleSpecifier.getText() 206 + ].fragments.concat(names); 207 + } 208 + } 209 + } else if ( 210 + imp.importClause.namedBindings && 211 + ts.isNamedImportBindings(imp.importClause.namedBindings) 212 + ) { 213 + imp.importClause.namedBindings.elements.forEach(el => { 214 + const definitions = info.languageService.getDefinitionAtPosition( 215 + source.fileName, 216 + el.getStart() 217 + ); 218 + if (definitions && definitions.length) { 219 + const [def] = definitions; 220 + if (def.fileName.includes('node_modules')) return; 221 + 222 + const externalSource = getSource(info, def.fileName); 223 + if (!externalSource) return; 224 + 225 + const fragmentsForImport = getFragmentsInSource( 226 + externalSource, 227 + info 228 + ); 229 + const names = fragmentsForImport.map( 230 + fragment => fragment.name.value 231 + ); 232 + if ( 233 + names.length && 234 + !importSpecifierToFragments[imp.moduleSpecifier.getText()] 235 + ) { 236 + importSpecifierToFragments[imp.moduleSpecifier.getText()] = { 237 + start: imp.moduleSpecifier.getStart(), 238 + length: imp.moduleSpecifier.getText().length, 239 + fragments: names, 240 + }; 241 + } else if (names.length) { 242 + importSpecifierToFragments[ 243 + imp.moduleSpecifier.getText() 244 + ].fragments = 245 + importSpecifierToFragments[ 246 + imp.moduleSpecifier.getText() 247 + ].fragments.concat(names); 248 + } 249 + } 250 + }); 251 + } 252 + }); 253 + } 254 + 255 + return importSpecifierToFragments; 256 + }; 257 + 258 + function getFragmentsInSource( 259 + src: ts.SourceFile, 260 + info: ts.server.PluginCreateInfo 261 + ): Array<FragmentDefinitionNode> { 262 + let fragments: Array<FragmentDefinitionNode> = []; 263 + const tagTemplate = info.config.template || 'gql'; 264 + const callExpressions = findAllCallExpressions(src, tagTemplate, info, false); 265 + 266 + callExpressions.nodes.forEach(node => { 267 + const text = resolveTemplate(node, src.fileName, info).combinedText; 268 + try { 269 + const parsed = parse(text, { noLocation: true }); 270 + if (parsed.definitions.every(x => x.kind === Kind.FRAGMENT_DEFINITION)) { 271 + fragments = fragments.concat(parsed.definitions as any); 272 + } 273 + } catch (e) { 274 + return; 275 + } 276 + }); 277 + 278 + return fragments; 279 + }
+70 -7
packages/graphqlsp/src/diagnostics.ts
··· 7 7 OperationDefinitionNode, 8 8 parse, 9 9 print, 10 + visit, 10 11 } from 'graphql'; 11 12 import { LRUCache } from 'lru-cache'; 12 13 import fnv1a from '@sindresorhus/fnv1a'; ··· 20 21 import { resolveTemplate } from './ast/resolve'; 21 22 import { generateTypedDocumentNodes } from './graphql/generateTypes'; 22 23 import { checkFieldUsageInFile } from './fieldUsage'; 23 - import { checkImportsForFragments } from './checkImports'; 24 + import { 25 + MISSING_FRAGMENT_CODE, 26 + checkImportsForFragments, 27 + getColocatedFragmentNames, 28 + } from './checkImports'; 24 29 25 30 const clientDirectives = new Set([ 26 31 'populate', ··· 304 309 messageText: diag.message.split('\n')[0], 305 310 })); 306 311 307 - const importDiagnostics = isCallExpression 308 - ? checkFieldUsageInFile( 312 + if (isCallExpression) { 313 + const usageDiagnostics = checkFieldUsageInFile( 314 + source, 315 + nodes as ts.NoSubstitutionTemplateLiteral[], 316 + info 317 + ); 318 + 319 + const shouldCheckForColocatedFragments = 320 + info.config.shouldCheckForColocatedFragments ?? false; 321 + let fragmentDiagnostics: ts.Diagnostic[] = []; 322 + console.log( 323 + '[GraphhQLSP] Checking for colocated fragments ', 324 + !!shouldCheckForColocatedFragments 325 + ); 326 + if (shouldCheckForColocatedFragments) { 327 + const moduleSpecifierToFragments = getColocatedFragmentNames( 309 328 source, 310 - nodes as ts.NoSubstitutionTemplateLiteral[], 311 329 info 312 - ) 313 - : checkImportsForFragments(source, info); 330 + ); 331 + console.log( 332 + '[GraphhQLSP] Checking for colocated fragments ', 333 + JSON.stringify(moduleSpecifierToFragments, null, 2) 334 + ); 314 335 315 - return [...tsDiagnostics, ...importDiagnostics]; 336 + const usedFragments = new Set(); 337 + nodes.forEach(node => { 338 + try { 339 + const parsed = parse(node.getText().slice(1, -1), { 340 + noLocation: true, 341 + }); 342 + visit(parsed, { 343 + FragmentSpread: node => { 344 + usedFragments.add(node.name.value); 345 + }, 346 + }); 347 + } catch (e) {} 348 + }); 349 + 350 + Object.keys(moduleSpecifierToFragments).forEach(moduleSpecifier => { 351 + const { 352 + fragments: fragmentNames, 353 + start, 354 + length, 355 + } = moduleSpecifierToFragments[moduleSpecifier]; 356 + const missingFragments = fragmentNames.filter( 357 + x => !usedFragments.has(x) 358 + ); 359 + if (missingFragments.length) { 360 + fragmentDiagnostics.push({ 361 + file: source, 362 + length, 363 + start, 364 + category: ts.DiagnosticCategory.Warning, 365 + code: MISSING_FRAGMENT_CODE, 366 + messageText: `Unused co-located fragment definition(s) "${missingFragments.join( 367 + ', ' 368 + )}" in ${moduleSpecifier}`, 369 + }); 370 + } 371 + }); 372 + } 373 + 374 + return [...tsDiagnostics, ...usageDiagnostics, ...fragmentDiagnostics]; 375 + } else { 376 + const importDiagnostics = checkImportsForFragments(source, info); 377 + return [...tsDiagnostics, ...importDiagnostics]; 378 + } 316 379 }; 317 380 318 381 const runTypedDocumentNodes = (
+82 -121
test/e2e/client-preset.test.ts
··· 10 10 const projectPath = path.resolve(__dirname, 'fixture-project-client-preset'); 11 11 describe('Fragment + operations', () => { 12 12 const outfileCombo = path.join(projectPath, 'simple.ts'); 13 + const outfileUnusedFragment = path.join(projectPath, 'unused-fragment.ts'); 13 14 const outfileCombinations = path.join(projectPath, 'fragment.ts'); 14 15 const outfileGql = path.join(projectPath, 'gql', 'gql.ts'); 15 16 const outfileGraphql = path.join(projectPath, 'gql', 'graphql.ts'); ··· 25 26 } satisfies ts.server.protocol.OpenRequestArgs); 26 27 server.sendCommand('open', { 27 28 file: outfileCombinations, 29 + fileContent: '// empty', 30 + scriptKindName: 'TS', 31 + } satisfies ts.server.protocol.OpenRequestArgs); 32 + server.sendCommand('open', { 33 + file: outfileUnusedFragment, 28 34 fileContent: '// empty', 29 35 scriptKindName: 'TS', 30 36 } satisfies ts.server.protocol.OpenRequestArgs); ··· 66 72 file: outfileCombo, 67 73 fileContent: fs.readFileSync( 68 74 path.join(projectPath, 'fixtures/simple.ts'), 75 + 'utf-8' 76 + ), 77 + }, 78 + { 79 + file: outfileUnusedFragment, 80 + fileContent: fs.readFileSync( 81 + path.join(projectPath, 'fixtures/unused-fragment.ts'), 69 82 'utf-8' 70 83 ), 71 84 }, ··· 88 101 file: outfileCombinations, 89 102 tmpfile: outfileCombinations, 90 103 } satisfies ts.server.protocol.SavetoRequestArgs); 104 + server.sendCommand('saveto', { 105 + file: outfileUnusedFragment, 106 + tmpfile: outfileUnusedFragment, 107 + } satisfies ts.server.protocol.SavetoRequestArgs); 91 108 }); 92 109 93 110 afterAll(() => { 94 111 try { 112 + fs.unlinkSync(outfileUnusedFragment); 95 113 fs.unlinkSync(outfileCombinations); 96 114 fs.unlinkSync(outfileCombo); 97 115 fs.unlinkSync(outfileGql); ··· 104 122 e => e.type === 'event' && e.event === 'semanticDiag' 105 123 ); 106 124 const res = server.responses.filter( 107 - resp => resp.type === 'event' && resp.event === 'semanticDiag' 125 + resp => 126 + resp.type === 'event' && 127 + resp.event === 'semanticDiag' && 128 + resp.body?.file === outfileCombo 108 129 ); 109 130 expect(res[0].body.diagnostics).toMatchInlineSnapshot(` 110 131 [ ··· 212 233 expect(res?.body.entries).toMatchInlineSnapshot(` 213 234 [ 214 235 { 215 - "kind": "var", 216 - "kindModifiers": "declare", 217 - "labelDetails": { 218 - "detail": " AttacksConnection", 236 + "kind": "string", 237 + "kindModifiers": "", 238 + "name": "\\\\n fragment pokemonFields on Pokemon {\\\\n id\\\\n name\\\\n attacks {\\\\n fast {\\\\n damage\\\\n name\\\\n }\\\\n }\\\\n }\\\\n", 239 + "replacementSpan": { 240 + "end": { 241 + "line": 9, 242 + "offset": 1, 243 + }, 244 + "start": { 245 + "line": 3, 246 + "offset": 39, 247 + }, 219 248 }, 220 - "name": "attacks", 221 - "sortText": "0attacks", 249 + "sortText": "11", 222 250 }, 223 251 { 224 - "kind": "var", 225 - "kindModifiers": "declare", 226 - "labelDetails": { 227 - "detail": " [EvolutionRequirement]", 252 + "kind": "string", 253 + "kindModifiers": "", 254 + "name": "\\\\n query Pok($limit: Int!) {\\\\n pokemons(limit: $limit) {\\\\n id\\\\n name\\\\n fleeRate\\\\n classification\\\\n ...pokemonFields\\\\n ...weaknessFields\\\\n __typename\\\\n }\\\\n }\\\\n", 255 + "replacementSpan": { 256 + "end": { 257 + "line": 9, 258 + "offset": 1, 259 + }, 260 + "start": { 261 + "line": 3, 262 + "offset": 39, 263 + }, 228 264 }, 229 - "name": "evolutionRequirements", 230 - "sortText": "2evolutionRequirements", 265 + "sortText": "11", 231 266 }, 267 + ] 268 + `); 269 + }, 30000); 270 + 271 + it('gives semantic-diagnostics with unused fragments', async () => { 272 + server.sendCommand('saveto', { 273 + file: outfileUnusedFragment, 274 + tmpfile: outfileUnusedFragment, 275 + } satisfies ts.server.protocol.SavetoRequestArgs); 276 + 277 + await server.waitForResponse( 278 + e => 279 + e.type === 'event' && 280 + e.event === 'semanticDiag' && 281 + e.body?.file === outfileUnusedFragment 282 + ); 283 + 284 + const res = server.responses.filter( 285 + resp => 286 + resp.type === 'event' && 287 + resp.event === 'semanticDiag' && 288 + resp.body?.file === outfileUnusedFragment 289 + ); 290 + expect(res[0].body.diagnostics).toMatchInlineSnapshot(` 291 + [ 232 292 { 233 - "kind": "var", 234 - "kindModifiers": "declare", 235 - "labelDetails": { 236 - "detail": " [Pokemon]", 293 + "category": "warning", 294 + "code": 52003, 295 + "end": { 296 + "line": 2, 297 + "offset": 37, 237 298 }, 238 - "name": "evolutions", 239 - "sortText": "3evolutions", 240 - }, 241 - { 242 - "kind": "var", 243 - "kindModifiers": "declare", 244 - "labelDetails": { 245 - "description": "Likelihood of an attempt to catch a Pokémon to fail.", 246 - "detail": " Float", 299 + "start": { 300 + "line": 2, 301 + "offset": 25, 247 302 }, 248 - "name": "fleeRate", 249 - "sortText": "4fleeRate", 250 - }, 251 - { 252 - "kind": "var", 253 - "kindModifiers": "declare", 254 - "labelDetails": { 255 - "detail": " PokemonDimension", 256 - }, 257 - "name": "height", 258 - "sortText": "5height", 259 - }, 260 - { 261 - "kind": "var", 262 - "kindModifiers": "declare", 263 - "labelDetails": { 264 - "detail": " ID!", 265 - }, 266 - "name": "id", 267 - "sortText": "6id", 268 - }, 269 - { 270 - "kind": "var", 271 - "kindModifiers": "declare", 272 - "labelDetails": { 273 - "description": "Maximum combat power a Pokémon may achieve at max level.", 274 - "detail": " Int", 275 - }, 276 - "name": "maxCP", 277 - "sortText": "7maxCP", 278 - }, 279 - { 280 - "kind": "var", 281 - "kindModifiers": "declare", 282 - "labelDetails": { 283 - "description": "Maximum health points a Pokémon may achieve at max level.", 284 - "detail": " Int", 285 - }, 286 - "name": "maxHP", 287 - "sortText": "8maxHP", 288 - }, 289 - { 290 - "kind": "var", 291 - "kindModifiers": "declare", 292 - "labelDetails": { 293 - "detail": " String!", 294 - }, 295 - "name": "name", 296 - "sortText": "9name", 297 - }, 298 - { 299 - "kind": "var", 300 - "kindModifiers": "declare", 301 - "labelDetails": { 302 - "detail": " [PokemonType]", 303 - }, 304 - "name": "resistant", 305 - "sortText": "10resistant", 306 - }, 307 - { 308 - "kind": "var", 309 - "kindModifiers": "declare", 310 - "labelDetails": { 311 - "detail": " [PokemonType]", 312 - }, 313 - "name": "types", 314 - "sortText": "11types", 315 - }, 316 - { 317 - "kind": "var", 318 - "kindModifiers": "declare", 319 - "labelDetails": { 320 - "detail": " [PokemonType]", 321 - }, 322 - "name": "weaknesses", 323 - "sortText": "12weaknesses", 324 - }, 325 - { 326 - "kind": "var", 327 - "kindModifiers": "declare", 328 - "labelDetails": { 329 - "detail": " PokemonDimension", 330 - }, 331 - "name": "weight", 332 - "sortText": "13weight", 333 - }, 334 - { 335 - "kind": "var", 336 - "kindModifiers": "declare", 337 - "labelDetails": { 338 - "description": "The name of the current Object type at runtime.", 339 - "detail": " String!", 340 - }, 341 - "name": "__typename", 342 - "sortText": "14__typename", 303 + "text": "Unused co-located fragment definition(s) \\"pokemonFields\\" in './fragment'", 343 304 }, 344 305 ] 345 306 `);
+2 -1
test/e2e/fixture-project-client-preset/fixtures/fragment.ts
··· 5 5 id 6 6 name 7 7 fleeRate 8 - 9 8 } 10 9 `); 10 + 11 + export const Pokemon = () => {};
+13
test/e2e/fixture-project-client-preset/fixtures/unused-fragment.ts
··· 1 + import { graphql } from './gql/gql'; 2 + import { Pokemon } from './fragment'; 3 + 4 + const x = graphql(` 5 + query Pok($limit: Int!) { 6 + pokemons(limit: $limit) { 7 + id 8 + name 9 + } 10 + } 11 + `); 12 + 13 + console.log(Pokemon);
+1 -1
test/e2e/fixture-project-client-preset/tsconfig.json
··· 5 5 "name": "@0no-co/graphqlsp", 6 6 "schema": "./schema.graphql", 7 7 "disableTypegen": true, 8 - "shouldCheckForColocatedFragments": false, 8 + "shouldCheckForColocatedFragments": true, 9 9 "template": "graphql", 10 10 "templateIsCallExpression": true 11 11 }