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.

chore(perf): add caching for diagnostics (#104)

authored by

Jovi De Croock and committed by
GitHub
4e866a65 3c4d18d3

+266 -201
+5
.changeset/moody-clocks-approve.md
··· 1 + --- 2 + '@0no-co/graphqlsp': patch 3 + --- 4 + 5 + add caching for gql-diagnostics
+8
packages/example/src/index.ts
··· 38 38 .then(result => { 39 39 result.data?.pokemon; 40 40 }); 41 + 42 + const myQuery = gql` 43 + query PokemonsAreAwesome { 44 + pokemons { 45 + id 46 + } 47 + } 48 + `;
+2
packages/graphqlsp/package.json
··· 46 46 "@graphql-codegen/typescript": "^4.0.1", 47 47 "@graphql-codegen/typescript-operations": "^4.0.1", 48 48 "@graphql-typed-document-node/core": "^3.2.0", 49 + "@sindresorhus/fnv1a": "^2.0.0", 49 50 "graphql-language-service": "^5.1.7", 51 + "lru-cache": "^10.0.1", 50 52 "node-fetch": "^2.0.0" 51 53 }, 52 54 "publishConfig": {
+219 -199
packages/graphqlsp/src/diagnostics.ts
··· 18 18 OperationDefinitionNode, 19 19 parse, 20 20 } from 'graphql'; 21 + import { LRUCache } from 'lru-cache'; 22 + import fnv1a from '@sindresorhus/fnv1a'; 21 23 22 24 import { 23 25 findAllImports, ··· 35 37 36 38 let isGeneratingTypes = false; 37 39 40 + const cache = new LRUCache<number, ts.Diagnostic[]>({ 41 + // how long to live in ms 42 + ttl: 1000 * 60 * 15, 43 + max: 5000, 44 + }); 45 + 38 46 export function getGraphQLDiagnostics( 39 47 // This is so that we don't change offsets when there are 40 48 // TypeScript errors 41 49 hasTSErrors: Boolean, 42 50 filename: string, 43 51 baseTypesPath: string, 44 - schema: { current: GraphQLSchema | null }, 52 + schema: { current: GraphQLSchema | null; version: number }, 45 53 info: ts.server.PluginCreateInfo 46 54 ): ts.Diagnostic[] | undefined { 47 55 const logger = (msg: string) => ··· 69 77 return resolveTemplate(node, filename, info).combinedText; 70 78 }); 71 79 72 - const diagnostics = nodes 73 - .map(originalNode => { 74 - let node = originalNode; 75 - if (isNoSubstitutionTemplateLiteral(node) || isTemplateExpression(node)) { 76 - if (isTaggedTemplateExpression(node.parent)) { 77 - node = node.parent; 78 - } else { 79 - return undefined; 80 + let tsDiagnostics: ts.Diagnostic[] = []; 81 + const cacheKey = fnv1a(texts.join('-') + schema.version); 82 + if (cache.has(cacheKey)) { 83 + tsDiagnostics = cache.get(cacheKey)!; 84 + } else { 85 + const diagnostics = nodes 86 + .map(originalNode => { 87 + let node = originalNode; 88 + if ( 89 + isNoSubstitutionTemplateLiteral(node) || 90 + isTemplateExpression(node) 91 + ) { 92 + if (isTaggedTemplateExpression(node.parent)) { 93 + node = node.parent; 94 + } else { 95 + return undefined; 96 + } 80 97 } 81 - } 82 98 83 - const { combinedText: text, resolvedSpans } = resolveTemplate( 84 - node, 85 - filename, 86 - info 87 - ); 88 - const lines = text.split('\n'); 99 + const { combinedText: text, resolvedSpans } = resolveTemplate( 100 + node, 101 + filename, 102 + info 103 + ); 104 + const lines = text.split('\n'); 89 105 90 - let isExpression = false; 91 - if (isAsExpression(node.parent)) { 92 - if (isExpressionStatement(node.parent.parent)) { 93 - isExpression = true; 106 + let isExpression = false; 107 + if (isAsExpression(node.parent)) { 108 + if (isExpressionStatement(node.parent.parent)) { 109 + isExpression = true; 110 + } 111 + } else { 112 + if (isExpressionStatement(node.parent)) { 113 + isExpression = true; 114 + } 94 115 } 95 - } else { 96 - if (isExpressionStatement(node.parent)) { 97 - isExpression = true; 98 - } 99 - } 100 - // When we are dealing with a plain gql statement we have to add two these can be recognised 101 - // by the fact that the parent is an expressionStatement 102 - let startingPosition = 103 - node.pos + (tagTemplate.length + (isExpression ? 2 : 1)); 104 - const endPosition = startingPosition + node.getText().length; 105 - const graphQLDiagnostics = getDiagnostics(text, schema.current) 106 - .map(x => { 107 - const { start, end } = x.range; 116 + // When we are dealing with a plain gql statement we have to add two these can be recognised 117 + // by the fact that the parent is an expressionStatement 118 + let startingPosition = 119 + node.pos + (tagTemplate.length + (isExpression ? 2 : 1)); 120 + const endPosition = startingPosition + node.getText().length; 121 + const graphQLDiagnostics = getDiagnostics(text, schema.current) 122 + .map(x => { 123 + const { start, end } = x.range; 108 124 109 - // We add the start.line to account for newline characters which are 110 - // split out 111 - let startChar = startingPosition + start.line; 112 - for (let i = 0; i <= start.line; i++) { 113 - if (i === start.line) startChar += start.character; 114 - else startChar += lines[i].length; 115 - } 125 + // We add the start.line to account for newline characters which are 126 + // split out 127 + let startChar = startingPosition + start.line; 128 + for (let i = 0; i <= start.line; i++) { 129 + if (i === start.line) startChar += start.character; 130 + else startChar += lines[i].length; 131 + } 116 132 117 - let endChar = startingPosition + end.line; 118 - for (let i = 0; i <= end.line; i++) { 119 - if (i === end.line) endChar += end.character; 120 - else endChar += lines[i].length; 121 - } 133 + let endChar = startingPosition + end.line; 134 + for (let i = 0; i <= end.line; i++) { 135 + if (i === end.line) endChar += end.character; 136 + else endChar += lines[i].length; 137 + } 122 138 123 - const locatedInFragment = resolvedSpans.find(x => { 124 - const newEnd = x.new.start + x.new.length; 125 - return startChar >= x.new.start && endChar <= newEnd; 126 - }); 139 + const locatedInFragment = resolvedSpans.find(x => { 140 + const newEnd = x.new.start + x.new.length; 141 + return startChar >= x.new.start && endChar <= newEnd; 142 + }); 127 143 128 - if (!!locatedInFragment) { 129 - return { 130 - ...x, 131 - start: locatedInFragment.original.start, 132 - length: locatedInFragment.original.length, 133 - }; 134 - } else { 135 - if (startChar > endPosition) { 136 - // we have to calculate the added length and fix this 137 - const addedCharacters = resolvedSpans 138 - .filter(x => x.new.start + x.new.length < startChar) 139 - .reduce( 140 - (acc, span) => acc + (span.new.length - span.original.length), 141 - 0 142 - ); 143 - startChar = startChar - addedCharacters; 144 - endChar = endChar - addedCharacters; 144 + if (!!locatedInFragment) { 145 145 return { 146 146 ...x, 147 - start: startChar + 1, 148 - length: endChar - startChar, 147 + start: locatedInFragment.original.start, 148 + length: locatedInFragment.original.length, 149 149 }; 150 150 } else { 151 - return { 152 - ...x, 153 - start: startChar + 1, 154 - length: endChar - startChar, 155 - }; 151 + if (startChar > endPosition) { 152 + // we have to calculate the added length and fix this 153 + const addedCharacters = resolvedSpans 154 + .filter(x => x.new.start + x.new.length < startChar) 155 + .reduce( 156 + (acc, span) => 157 + acc + (span.new.length - span.original.length), 158 + 0 159 + ); 160 + startChar = startChar - addedCharacters; 161 + endChar = endChar - addedCharacters; 162 + return { 163 + ...x, 164 + start: startChar + 1, 165 + length: endChar - startChar, 166 + }; 167 + } else { 168 + return { 169 + ...x, 170 + start: startChar + 1, 171 + length: endChar - startChar, 172 + }; 173 + } 156 174 } 157 - } 158 - }) 159 - .filter(x => x.start + x.length <= endPosition); 175 + }) 176 + .filter(x => x.start + x.length <= endPosition); 160 177 161 - try { 162 - const parsed = parse(text, { noLocation: true }); 178 + try { 179 + const parsed = parse(text, { noLocation: true }); 163 180 164 - if ( 165 - parsed.definitions.some(x => x.kind === Kind.OPERATION_DEFINITION) 166 - ) { 167 - const op = parsed.definitions.find( 168 - x => x.kind === Kind.OPERATION_DEFINITION 169 - ) as OperationDefinitionNode; 170 - if (!op.name) { 171 - graphQLDiagnostics.push({ 172 - message: 'Operation needs a name for types to be generated.', 173 - start: node.pos, 174 - code: MISSING_OPERATION_NAME_CODE, 175 - length: originalNode.getText().length, 176 - range: {} as any, 177 - severity: 2, 178 - } as any); 181 + if ( 182 + parsed.definitions.some(x => x.kind === Kind.OPERATION_DEFINITION) 183 + ) { 184 + const op = parsed.definitions.find( 185 + x => x.kind === Kind.OPERATION_DEFINITION 186 + ) as OperationDefinitionNode; 187 + if (!op.name) { 188 + graphQLDiagnostics.push({ 189 + message: 'Operation needs a name for types to be generated.', 190 + start: node.pos, 191 + code: MISSING_OPERATION_NAME_CODE, 192 + length: originalNode.getText().length, 193 + range: {} as any, 194 + severity: 2, 195 + } as any); 196 + } 179 197 } 180 - } 181 - } catch (e) {} 182 - 183 - return graphQLDiagnostics; 184 - }) 185 - .flat() 186 - .filter(Boolean) as Array<Diagnostic & { length: number; start: number }>; 198 + } catch (e) {} 187 199 188 - const tsDiagnostics: ts.Diagnostic[] = diagnostics.map(diag => ({ 189 - file: source, 190 - length: diag.length, 191 - start: diag.start, 192 - category: 193 - diag.severity === 2 194 - ? ts.DiagnosticCategory.Warning 195 - : ts.DiagnosticCategory.Error, 196 - code: 197 - typeof diag.code === 'number' 198 - ? diag.code 199 - : diag.severity === 2 200 - ? USING_DEPRECATED_FIELD_CODE 201 - : SEMANTIC_DIAGNOSTIC_CODE, 202 - messageText: diag.message.split('\n')[0], 203 - })); 200 + return graphQLDiagnostics; 201 + }) 202 + .flat() 203 + .filter(Boolean) as Array<Diagnostic & { length: number; start: number }>; 204 204 205 - const imports = findAllImports(source); 206 - if (imports.length && shouldCheckForColocatedFragments) { 207 - const typeChecker = info.languageService.getProgram()?.getTypeChecker(); 208 - imports.forEach(imp => { 209 - if (!imp.importClause) return; 205 + tsDiagnostics = diagnostics.map(diag => ({ 206 + file: source, 207 + length: diag.length, 208 + start: diag.start, 209 + category: 210 + diag.severity === 2 211 + ? ts.DiagnosticCategory.Warning 212 + : ts.DiagnosticCategory.Error, 213 + code: 214 + typeof diag.code === 'number' 215 + ? diag.code 216 + : diag.severity === 2 217 + ? USING_DEPRECATED_FIELD_CODE 218 + : SEMANTIC_DIAGNOSTIC_CODE, 219 + messageText: diag.message.split('\n')[0], 220 + })); 210 221 211 - const importedNames: string[] = []; 212 - if (imp.importClause.name) { 213 - importedNames.push(imp.importClause?.name.text); 214 - } 222 + const imports = findAllImports(source); 223 + if (imports.length && shouldCheckForColocatedFragments) { 224 + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); 225 + imports.forEach(imp => { 226 + if (!imp.importClause) return; 215 227 216 - if ( 217 - imp.importClause.namedBindings && 218 - isNamespaceImport(imp.importClause.namedBindings) 219 - ) { 220 - // TODO: we might need to warn here when the fragment is unused as a namespace import 221 - return; 222 - } else if ( 223 - imp.importClause.namedBindings && 224 - isNamedImportBindings(imp.importClause.namedBindings) 225 - ) { 226 - imp.importClause.namedBindings.elements.forEach(el => { 227 - importedNames.push(el.name.text); 228 - }); 229 - } 228 + const importedNames: string[] = []; 229 + if (imp.importClause.name) { 230 + importedNames.push(imp.importClause?.name.text); 231 + } 230 232 231 - const symbol = typeChecker?.getSymbolAtLocation(imp.moduleSpecifier); 232 - if (!symbol) return; 233 + if ( 234 + imp.importClause.namedBindings && 235 + isNamespaceImport(imp.importClause.namedBindings) 236 + ) { 237 + // TODO: we might need to warn here when the fragment is unused as a namespace import 238 + return; 239 + } else if ( 240 + imp.importClause.namedBindings && 241 + isNamedImportBindings(imp.importClause.namedBindings) 242 + ) { 243 + imp.importClause.namedBindings.elements.forEach(el => { 244 + importedNames.push(el.name.text); 245 + }); 246 + } 233 247 234 - const moduleExports = typeChecker?.getExportsOfModule(symbol); 235 - if (!moduleExports) return; 248 + const symbol = typeChecker?.getSymbolAtLocation(imp.moduleSpecifier); 249 + if (!symbol) return; 236 250 237 - const missingImports = moduleExports 238 - .map(exp => { 239 - if (importedNames.includes(exp.name)) { 240 - return; 241 - } 251 + const moduleExports = typeChecker?.getExportsOfModule(symbol); 252 + if (!moduleExports) return; 242 253 243 - const declarations = exp.getDeclarations(); 244 - const declaration = declarations?.find(x => { 245 - // TODO: check whether the sourceFile.fileName resembles the module 246 - // specifier 247 - return true; 248 - }); 254 + const missingImports = moduleExports 255 + .map(exp => { 256 + if (importedNames.includes(exp.name)) { 257 + return; 258 + } 249 259 250 - if (!declaration) return; 260 + const declarations = exp.getDeclarations(); 261 + const declaration = declarations?.find(x => { 262 + // TODO: check whether the sourceFile.fileName resembles the module 263 + // specifier 264 + return true; 265 + }); 251 266 252 - const [template] = findAllTaggedTemplateNodes(declaration); 253 - if (template) { 254 - let node = template; 255 - if ( 256 - isNoSubstitutionTemplateLiteral(node) || 257 - isTemplateExpression(node) 258 - ) { 259 - if (isTaggedTemplateExpression(node.parent)) { 260 - node = node.parent; 261 - } else { 262 - return; 263 - } 264 - } 267 + if (!declaration) return; 265 268 266 - const text = resolveTemplate( 267 - node, 268 - node.getSourceFile().fileName, 269 - info 270 - ).combinedText; 271 - try { 272 - const parsed = parse(text, { noLocation: true }); 269 + const [template] = findAllTaggedTemplateNodes(declaration); 270 + if (template) { 271 + let node = template; 273 272 if ( 274 - parsed.definitions.every( 275 - x => x.kind === Kind.FRAGMENT_DEFINITION 276 - ) 273 + isNoSubstitutionTemplateLiteral(node) || 274 + isTemplateExpression(node) 277 275 ) { 278 - return `'${exp.name}'`; 276 + if (isTaggedTemplateExpression(node.parent)) { 277 + node = node.parent; 278 + } else { 279 + return; 280 + } 279 281 } 280 - } catch (e) { 281 - return; 282 + 283 + const text = resolveTemplate( 284 + node, 285 + node.getSourceFile().fileName, 286 + info 287 + ).combinedText; 288 + try { 289 + const parsed = parse(text, { noLocation: true }); 290 + if ( 291 + parsed.definitions.every( 292 + x => x.kind === Kind.FRAGMENT_DEFINITION 293 + ) 294 + ) { 295 + return `'${exp.name}'`; 296 + } 297 + } catch (e) { 298 + return; 299 + } 282 300 } 283 - } 284 - }) 285 - .filter(Boolean); 301 + }) 302 + .filter(Boolean); 286 303 287 - if (missingImports.length) { 288 - // TODO: we could use getCodeFixesAtPosition 289 - // to build on this 290 - tsDiagnostics.push({ 291 - file: source, 292 - length: imp.getText().length, 293 - start: imp.getStart(), 294 - category: ts.DiagnosticCategory.Message, 295 - code: MISSING_FRAGMENT_CODE, 296 - messageText: `Missing Fragment import(s) ${missingImports.join( 297 - ', ' 298 - )} from ${imp.moduleSpecifier.getText()}.`, 299 - }); 300 - } 301 - }); 304 + if (missingImports.length) { 305 + // TODO: we could use getCodeFixesAtPosition 306 + // to build on this 307 + tsDiagnostics.push({ 308 + file: source, 309 + length: imp.getText().length, 310 + start: imp.getStart(), 311 + category: ts.DiagnosticCategory.Message, 312 + code: MISSING_FRAGMENT_CODE, 313 + messageText: `Missing Fragment import(s) ${missingImports.join( 314 + ', ' 315 + )} from ${imp.moduleSpecifier.getText()}.`, 316 + }); 317 + } 318 + }); 319 + } 320 + 321 + cache.set(cacheKey, tsDiagnostics); 302 322 } 303 323 304 324 if (
+10 -2
packages/graphqlsp/src/graphql/getSchema.ts
··· 25 25 shouldTypegen: boolean, 26 26 scalars: Record<string, unknown>, 27 27 extraTypes?: string 28 - ): { current: GraphQLSchema | null } => { 29 - const ref: { current: GraphQLSchema | null } = { current: null }; 28 + ): { current: GraphQLSchema | null; version: number } => { 29 + const ref: { current: GraphQLSchema | null; version: number } = { 30 + current: null, 31 + version: 0, 32 + }; 30 33 let url: URL | undefined; 31 34 32 35 let isJSON = false; ··· 77 80 ref.current = buildClientSchema( 78 81 (result as { data: IntrospectionQuery }).data 79 82 ); 83 + ref.version = ref.version + 1; 80 84 logger(`Got schema for ${url!.toString()}`); 81 85 if (shouldTypegen) 82 86 generateBaseTypes( ··· 103 107 ref.current = isJson 104 108 ? buildClientSchema(JSON.parse(contents)) 105 109 : buildSchema(contents); 110 + ref.version = ref.version + 1; 111 + 106 112 if (shouldTypegen) generateBaseTypes(ref.current, baseTypesPath, scalars); 107 113 }); 108 114 109 115 ref.current = isJson 110 116 ? buildClientSchema(JSON.parse(contents)) 111 117 : buildSchema(contents); 118 + ref.version = ref.version + 1; 119 + 112 120 if (shouldTypegen) 113 121 generateBaseTypes(ref.current, baseTypesPath, scalars, extraTypes); 114 122 logger(`Got schema and initialized watcher for ${schema}`);
+22
pnpm-lock.yaml
··· 84 84 '@graphql-typed-document-node/core': 85 85 specifier: ^3.2.0 86 86 version: 3.2.0(graphql@16.6.0) 87 + '@sindresorhus/fnv1a': 88 + specifier: ^2.0.0 89 + version: 2.0.0 87 90 graphql-language-service: 88 91 specifier: ^5.1.7 89 92 version: 5.1.7(graphql@16.6.0) 93 + lru-cache: 94 + specifier: ^10.0.1 95 + version: 10.0.1 90 96 node-fetch: 91 97 specifier: ^2.0.0 92 98 version: 2.6.7 ··· 1354 1360 rollup: 3.20.2 1355 1361 dev: true 1356 1362 1363 + /@sindresorhus/fnv1a@2.0.0: 1364 + resolution: {integrity: sha512-HAK3TQvR1AbJ4uMFBp9K0V+jlujsHkCokWJRebQK1mlXBG+i7Q5/Y7AKRmqVPt4T78Uqp2U5fFHgK3gxi4OUqw==} 1365 + engines: {node: '>=10'} 1366 + dev: false 1367 + 1368 + /@sindresorhus/fnv1a@2.0.1: 1369 + resolution: {integrity: sha512-suq9tRQ6bkpMukTG5K5z0sPWB7t0zExMzZCdmYm6xTSSIm/yCKNm7VCL36wVeyTsFr597/UhU1OAYdHGMDiHrw==} 1370 + engines: {node: '>=10'} 1371 + dev: true 1372 + 1357 1373 /@types/chai-subset@1.3.3: 1358 1374 resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} 1359 1375 dependencies: ··· 2927 2943 dependencies: 2928 2944 tslib: 2.5.0 2929 2945 2946 + /lru-cache@10.0.1: 2947 + resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==} 2948 + engines: {node: 14 || >=16.14} 2949 + 2930 2950 /lru-cache@4.1.5: 2931 2951 resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} 2932 2952 dependencies: ··· 4327 4347 '@graphql-codegen/typescript': 4.0.1(graphql@16.6.0) 4328 4348 '@graphql-codegen/typescript-operations': 4.0.1(graphql@16.6.0) 4329 4349 '@graphql-typed-document-node/core': 3.2.0(graphql@16.6.0) 4350 + '@sindresorhus/fnv1a': 2.0.1 4330 4351 graphql-language-service: 5.1.7(graphql@16.6.0) 4352 + lru-cache: 10.0.1 4331 4353 node-fetch: 2.6.7 4332 4354 transitivePeerDependencies: 4333 4355 - encoding