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.

Refactors (#45)

* refactor autocomplete

* quick info

* move ast-utils in folder

* refactor diagnostics

* moving stuff around

* hoist template bubble up func

* refactor diagnostics

* refactor test suite

* add test for missing fragment import

* execute tests single threaded to ensure correctnes

* please work

authored by

Jovi De Croock and committed by
GitHub
8fb43270 1fbc8ebb

+789 -675
+1
.gitignore
··· 7 7 lerna-debug.log* 8 8 src/**/*.js 9 9 example/src/**/*.js 10 + test/e2e/fixture-project/__generated__/* 10 11 11 12 # Diagnostic reports (https://nodejs.org/api/report.html) 12 13 report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+1 -1
package.json
··· 9 9 "prepare": "husky install", 10 10 "dev": "pnpm --filter @0no-co/graphqlsp dev", 11 11 "launch-debug": "./scripts/launch-debug.sh", 12 - "test:e2e": "vitest run" 12 + "test:e2e": "vitest run --single-thread" 13 13 }, 14 14 "prettier": { 15 15 "singleQuote": true,
+50 -49
packages/example/__generated__/baseGraphQLSP.ts
··· 18 18 Float: number; 19 19 }; 20 20 21 - /** Elemental property associated with either a Pokémon or one of their moves. */ 22 - export type PokemonType = 23 - | 'Grass' 24 - | 'Poison' 25 - | 'Fire' 26 - | 'Flying' 27 - | 'Water' 28 - | 'Bug' 29 - | 'Normal' 30 - | 'Electric' 31 - | 'Ground' 32 - | 'Fairy' 33 - | 'Fighting' 34 - | 'Psychic' 35 - | 'Rock' 36 - | 'Steel' 37 - | 'Ice' 38 - | 'Ghost' 39 - | 'Dragon' 40 - | 'Dark'; 41 - 42 21 /** Move a Pokémon can perform with the associated damage and type. */ 43 22 export type Attack = { 44 23 __typename?: 'Attack'; 24 + damage?: Maybe<Scalars['Int']>; 45 25 name?: Maybe<Scalars['String']>; 46 26 type?: Maybe<PokemonType>; 47 - damage?: Maybe<Scalars['Int']>; 27 + }; 28 + 29 + export type AttacksConnection = { 30 + __typename?: 'AttacksConnection'; 31 + fast?: Maybe<Array<Maybe<Attack>>>; 32 + special?: Maybe<Array<Maybe<Attack>>>; 48 33 }; 49 34 50 35 /** Requirement that prevents an evolution through regular means of levelling up. */ ··· 54 39 name?: Maybe<Scalars['String']>; 55 40 }; 56 41 57 - export type PokemonDimension = { 58 - __typename?: 'PokemonDimension'; 59 - minimum?: Maybe<Scalars['String']>; 60 - maximum?: Maybe<Scalars['String']>; 61 - }; 62 - 63 - export type AttacksConnection = { 64 - __typename?: 'AttacksConnection'; 65 - fast?: Maybe<Array<Maybe<Attack>>>; 66 - special?: Maybe<Array<Maybe<Attack>>>; 67 - }; 68 - 69 42 export type Pokemon = { 70 43 __typename?: 'Pokemon'; 71 - id: Scalars['ID']; 72 - name: Scalars['String']; 44 + attacks?: Maybe<AttacksConnection>; 45 + /** @deprecated And this is the reason why */ 73 46 classification?: Maybe<Scalars['String']>; 74 - types?: Maybe<Array<Maybe<PokemonType>>>; 75 - resistant?: Maybe<Array<Maybe<PokemonType>>>; 76 - weaknesses?: Maybe<Array<Maybe<PokemonType>>>; 77 47 evolutionRequirements?: Maybe<Array<Maybe<EvolutionRequirement>>>; 78 - weight?: Maybe<PokemonDimension>; 79 - height?: Maybe<PokemonDimension>; 80 - attacks?: Maybe<AttacksConnection>; 48 + evolutions?: Maybe<Array<Maybe<Pokemon>>>; 81 49 /** Likelihood of an attempt to catch a Pokémon to fail. */ 82 50 fleeRate?: Maybe<Scalars['Float']>; 51 + height?: Maybe<PokemonDimension>; 52 + id: Scalars['ID']; 83 53 /** Maximum combat power a Pokémon may achieve at max level. */ 84 54 maxCP?: Maybe<Scalars['Int']>; 85 55 /** Maximum health points a Pokémon may achieve at max level. */ 86 56 maxHP?: Maybe<Scalars['Int']>; 87 - evolutions?: Maybe<Array<Maybe<Pokemon>>>; 57 + name: Scalars['String']; 58 + resistant?: Maybe<Array<Maybe<PokemonType>>>; 59 + types?: Maybe<Array<Maybe<PokemonType>>>; 60 + weaknesses?: Maybe<Array<Maybe<PokemonType>>>; 61 + weight?: Maybe<PokemonDimension>; 62 + }; 63 + 64 + export type PokemonDimension = { 65 + __typename?: 'PokemonDimension'; 66 + maximum?: Maybe<Scalars['String']>; 67 + minimum?: Maybe<Scalars['String']>; 88 68 }; 89 69 70 + /** Elemental property associated with either a Pokémon or one of their moves. */ 71 + export type PokemonType = 72 + | 'Bug' 73 + | 'Dark' 74 + | 'Dragon' 75 + | 'Electric' 76 + | 'Fairy' 77 + | 'Fighting' 78 + | 'Fire' 79 + | 'Flying' 80 + | 'Ghost' 81 + | 'Grass' 82 + | 'Ground' 83 + | 'Ice' 84 + | 'Normal' 85 + | 'Poison' 86 + | 'Psychic' 87 + | 'Rock' 88 + | 'Steel' 89 + | 'Water'; 90 + 90 91 export type Query = { 91 92 __typename?: 'Query'; 92 - /** List out all Pokémon, optionally in pages */ 93 - pokemons?: Maybe<Array<Maybe<Pokemon>>>; 94 93 /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ 95 94 pokemon?: Maybe<Pokemon>; 95 + /** List out all Pokémon, optionally in pages */ 96 + pokemons?: Maybe<Array<Maybe<Pokemon>>>; 97 + }; 98 + 99 + export type QueryPokemonArgs = { 100 + id: Scalars['ID']; 96 101 }; 97 102 98 103 export type QueryPokemonsArgs = { 99 104 limit?: InputMaybe<Scalars['Int']>; 100 105 skip?: InputMaybe<Scalars['Int']>; 101 106 }; 102 - 103 - export type QueryPokemonArgs = { 104 - id: Scalars['ID']; 105 - };
+1 -106
packages/example/src/Pokemon.generated.ts
··· 1 + import * as Types from '../__generated__/baseGraphQLSP'; 1 2 import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 2 - export type Maybe<T> = T | null; 3 - export type InputMaybe<T> = Maybe<T>; 4 - export type Exact<T extends { [key: string]: unknown }> = { 5 - [K in keyof T]: T[K]; 6 - }; 7 - export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { 8 - [SubKey in K]?: Maybe<T[SubKey]>; 9 - }; 10 - export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { 11 - [SubKey in K]: Maybe<T[SubKey]>; 12 - }; 13 - /** All built-in and custom scalars, mapped to their actual values */ 14 - export type Scalars = { 15 - ID: string; 16 - String: string; 17 - Boolean: boolean; 18 - Int: number; 19 - Float: number; 20 - }; 21 - 22 - /** Elemental property associated with either a Pokémon or one of their moves. */ 23 - export type PokemonType = 24 - | 'Grass' 25 - | 'Poison' 26 - | 'Fire' 27 - | 'Flying' 28 - | 'Water' 29 - | 'Bug' 30 - | 'Normal' 31 - | 'Electric' 32 - | 'Ground' 33 - | 'Fairy' 34 - | 'Fighting' 35 - | 'Psychic' 36 - | 'Rock' 37 - | 'Steel' 38 - | 'Ice' 39 - | 'Ghost' 40 - | 'Dragon' 41 - | 'Dark'; 42 - 43 - /** Move a Pokémon can perform with the associated damage and type. */ 44 - export type Attack = { 45 - __typename?: 'Attack'; 46 - name?: Maybe<Scalars['String']>; 47 - type?: Maybe<PokemonType>; 48 - damage?: Maybe<Scalars['Int']>; 49 - }; 50 - 51 - /** Requirement that prevents an evolution through regular means of levelling up. */ 52 - export type EvolutionRequirement = { 53 - __typename?: 'EvolutionRequirement'; 54 - amount?: Maybe<Scalars['Int']>; 55 - name?: Maybe<Scalars['String']>; 56 - }; 57 - 58 - export type PokemonDimension = { 59 - __typename?: 'PokemonDimension'; 60 - minimum?: Maybe<Scalars['String']>; 61 - maximum?: Maybe<Scalars['String']>; 62 - }; 63 - 64 - export type AttacksConnection = { 65 - __typename?: 'AttacksConnection'; 66 - fast?: Maybe<Array<Maybe<Attack>>>; 67 - special?: Maybe<Array<Maybe<Attack>>>; 68 - }; 69 - 70 - export type Pokemon = { 71 - __typename?: 'Pokemon'; 72 - id: Scalars['ID']; 73 - name: Scalars['String']; 74 - classification?: Maybe<Scalars['String']>; 75 - types?: Maybe<Array<Maybe<PokemonType>>>; 76 - resistant?: Maybe<Array<Maybe<PokemonType>>>; 77 - weaknesses?: Maybe<Array<Maybe<PokemonType>>>; 78 - evolutionRequirements?: Maybe<Array<Maybe<EvolutionRequirement>>>; 79 - weight?: Maybe<PokemonDimension>; 80 - height?: Maybe<PokemonDimension>; 81 - attacks?: Maybe<AttacksConnection>; 82 - /** Likelihood of an attempt to catch a Pokémon to fail. */ 83 - fleeRate?: Maybe<Scalars['Float']>; 84 - /** Maximum combat power a Pokémon may achieve at max level. */ 85 - maxCP?: Maybe<Scalars['Int']>; 86 - /** Maximum health points a Pokémon may achieve at max level. */ 87 - maxHP?: Maybe<Scalars['Int']>; 88 - evolutions?: Maybe<Array<Maybe<Pokemon>>>; 89 - }; 90 - 91 - export type Query = { 92 - __typename?: 'Query'; 93 - /** List out all Pokémon, optionally in pages */ 94 - pokemons?: Maybe<Array<Maybe<Pokemon>>>; 95 - /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ 96 - pokemon?: Maybe<Pokemon>; 97 - }; 98 - 99 - export type QueryPokemonsArgs = { 100 - limit?: InputMaybe<Scalars['Int']>; 101 - skip?: InputMaybe<Scalars['Int']>; 102 - }; 103 - 104 - export type QueryPokemonArgs = { 105 - id: Scalars['ID']; 106 - }; 107 - 108 3 export type PokemonFieldsFragment = { 109 4 __typename?: 'Pokemon'; 110 5 id: string;
+1 -1
packages/example/tsconfig.json
··· 3 3 "plugins": [ 4 4 { 5 5 "name": "@0no-co/graphqlsp", 6 - "schema": "https://trygql.formidable.dev/graphql/basic-pokedex" 6 + "schema": "./schema.graphql" 7 7 } 8 8 ], 9 9 /* Language and Environment */
+32
packages/graphqlsp/src/ast.ts packages/graphqlsp/src/ast/index.ts
··· 3 3 isImportDeclaration, 4 4 isNoSubstitutionTemplateLiteral, 5 5 isTaggedTemplateExpression, 6 + isTemplateExpression, 7 + isToken, 6 8 } from 'typescript'; 9 + import fs from 'fs'; 10 + 11 + export function isFileDirty(fileName: string, source: ts.SourceFile) { 12 + const contents = fs.readFileSync(fileName, 'utf-8'); 13 + const currentText = source.getFullText(); 14 + 15 + return currentText !== contents; 16 + } 17 + 18 + export function getSource(info: ts.server.PluginCreateInfo, filename: string) { 19 + const program = info.languageService.getProgram(); 20 + if (!program) return undefined; 21 + 22 + const source = program.getSourceFile(filename); 23 + if (!source) return undefined; 24 + 25 + return source; 26 + } 7 27 8 28 export function findNode( 9 29 sourceFile: ts.SourceFile, ··· 43 63 ): Array<ts.ImportDeclaration> { 44 64 return sourceFile.statements.filter(isImportDeclaration); 45 65 } 66 + 67 + export function bubbleUpTemplate(node: ts.Node): ts.Node { 68 + while ( 69 + isNoSubstitutionTemplateLiteral(node) || 70 + isToken(node) || 71 + isTemplateExpression(node) 72 + ) { 73 + node = node.parent; 74 + } 75 + 76 + return node; 77 + }
+103
packages/graphqlsp/src/autoComplete.ts
··· 1 + import ts from 'typescript/lib/tsserverlibrary'; 2 + import { 3 + ScriptElementKind, 4 + isIdentifier, 5 + isTaggedTemplateExpression, 6 + } from 'typescript'; 7 + import { 8 + getAutocompleteSuggestions, 9 + getTokenAtPosition, 10 + getTypeInfo, 11 + } from 'graphql-language-service'; 12 + import { FragmentDefinitionNode, GraphQLSchema, Kind, parse } from 'graphql'; 13 + 14 + import { bubbleUpTemplate, findNode, getSource } from './ast'; 15 + import { Cursor } from './ast/cursor'; 16 + import { resolveTemplate } from './ast/resolve'; 17 + import { getToken } from './ast/token'; 18 + import { getSuggestionsForFragmentSpread } from './graphql/getFragmentSpreadSuggestions'; 19 + 20 + export function getGraphQLCompletions( 21 + filename: string, 22 + cursorPosition: number, 23 + schema: { current: GraphQLSchema | null }, 24 + info: ts.server.PluginCreateInfo 25 + ): ts.WithMetadata<ts.CompletionInfo> | undefined { 26 + const tagTemplate = info.config.template || 'gql'; 27 + 28 + const source = getSource(info, filename); 29 + if (!source) return undefined; 30 + 31 + let node = findNode(source, cursorPosition); 32 + if (!node) return undefined; 33 + 34 + node = bubbleUpTemplate(node); 35 + 36 + if (isTaggedTemplateExpression(node)) { 37 + const { template, tag } = node; 38 + 39 + if (!isIdentifier(tag) || tag.text !== tagTemplate) return undefined; 40 + 41 + const foundToken = getToken(template, cursorPosition); 42 + if (!foundToken || !schema.current) return undefined; 43 + 44 + const text = resolveTemplate(node, filename, info); 45 + let fragments: Array<FragmentDefinitionNode> = []; 46 + try { 47 + const parsed = parse(text); 48 + fragments = parsed.definitions.filter( 49 + x => x.kind === Kind.FRAGMENT_DEFINITION 50 + ) as Array<FragmentDefinitionNode>; 51 + } catch (e) {} 52 + 53 + const cursor = new Cursor(foundToken.line, foundToken.start); 54 + const suggestions = getAutocompleteSuggestions( 55 + schema.current, 56 + text, 57 + cursor 58 + ); 59 + 60 + const token = getTokenAtPosition(text, cursor); 61 + const spreadSuggestions = getSuggestionsForFragmentSpread( 62 + token, 63 + getTypeInfo(schema.current, token.state), 64 + schema.current, 65 + text, 66 + fragments 67 + ); 68 + 69 + return { 70 + isGlobalCompletion: false, 71 + isMemberCompletion: false, 72 + isNewIdentifierLocation: false, 73 + entries: [ 74 + ...suggestions.map(suggestion => ({ 75 + ...suggestion, 76 + kind: ScriptElementKind.variableElement, 77 + name: suggestion.label, 78 + kindModifiers: 'declare', 79 + sortText: suggestion.sortText || '0', 80 + labelDetails: { 81 + detail: suggestion.type 82 + ? ' ' + suggestion.type?.toString() 83 + : undefined, 84 + description: suggestion.documentation, 85 + }, 86 + })), 87 + ...spreadSuggestions.map(suggestion => ({ 88 + ...suggestion, 89 + kind: ScriptElementKind.variableElement, 90 + name: suggestion.label, 91 + insertText: '...' + suggestion.label, 92 + kindModifiers: 'declare', 93 + sortText: '0', 94 + labelDetails: { 95 + description: suggestion.documentation, 96 + }, 97 + })), 98 + ], 99 + }; 100 + } else { 101 + return undefined; 102 + } 103 + }
packages/graphqlsp/src/cursor.ts packages/graphqlsp/src/ast/cursor.ts
+337
packages/graphqlsp/src/diagnostics.ts
··· 1 + import ts from 'typescript/lib/tsserverlibrary'; 2 + import { 3 + ImportTypeNode, 4 + isImportTypeNode, 5 + isNamedImportBindings, 6 + isNamespaceImport, 7 + isNoSubstitutionTemplateLiteral, 8 + isTaggedTemplateExpression, 9 + isTemplateExpression, 10 + } from 'typescript'; 11 + import { Diagnostic, getDiagnostics } from 'graphql-language-service'; 12 + import { 13 + FragmentDefinitionNode, 14 + GraphQLSchema, 15 + Kind, 16 + OperationDefinitionNode, 17 + parse, 18 + } from 'graphql'; 19 + 20 + import { 21 + findAllImports, 22 + findAllTaggedTemplateNodes, 23 + getSource, 24 + isFileDirty, 25 + } from './ast'; 26 + import { resolveTemplate } from './ast/resolve'; 27 + import { generateTypedDocumentNodes } from './graphql/generateTypes'; 28 + 29 + export const SEMANTIC_DIAGNOSTIC_CODE = 51001; 30 + 31 + export function getGraphQLDiagnostics( 32 + filename: string, 33 + baseTypesPath: string, 34 + schema: { current: GraphQLSchema | null }, 35 + info: ts.server.PluginCreateInfo 36 + ): ts.Diagnostic[] | undefined { 37 + const tagTemplate = info.config.template || 'gql'; 38 + const scalars = info.config.scalars || {}; 39 + const shouldCheckForColocatedFragments = 40 + info.config.shouldCheckForColocatedFragments || true; 41 + 42 + const source = getSource(info, filename); 43 + if (!source) return undefined; 44 + 45 + const nodes = findAllTaggedTemplateNodes(source); 46 + 47 + const texts = nodes.map(node => { 48 + if (isNoSubstitutionTemplateLiteral(node) || isTemplateExpression(node)) { 49 + if (isTaggedTemplateExpression(node.parent)) { 50 + node = node.parent; 51 + } else { 52 + return undefined; 53 + } 54 + } 55 + 56 + return resolveTemplate(node, filename, info); 57 + }); 58 + 59 + const diagnostics = nodes 60 + .map(originalNode => { 61 + let node = originalNode; 62 + if (isNoSubstitutionTemplateLiteral(node) || isTemplateExpression(node)) { 63 + if (isTaggedTemplateExpression(node.parent)) { 64 + node = node.parent; 65 + } else { 66 + return undefined; 67 + } 68 + } 69 + 70 + const text = resolveTemplate(node, filename, info); 71 + const lines = text.split('\n'); 72 + 73 + let startingPosition = node.pos + (tagTemplate.length + 1); 74 + const graphQLDiagnostics = getDiagnostics(text, schema.current).map(x => { 75 + const { start, end } = x.range; 76 + 77 + // We add the start.line to account for newline characters which are 78 + // split out 79 + let startChar = startingPosition + start.line; 80 + for (let i = 0; i <= start.line; i++) { 81 + if (i === start.line) startChar += start.character; 82 + else startChar += lines[i].length; 83 + } 84 + 85 + let endChar = startingPosition + end.line; 86 + for (let i = 0; i <= end.line; i++) { 87 + if (i === end.line) endChar += end.character; 88 + else endChar += lines[i].length; 89 + } 90 + 91 + // We add 1 to the start because the range is exclusive of start.character 92 + return { ...x, start: startChar + 1, length: endChar - startChar }; 93 + }); 94 + 95 + try { 96 + const parsed = parse(text); 97 + 98 + if ( 99 + parsed.definitions.some(x => x.kind === Kind.OPERATION_DEFINITION) 100 + ) { 101 + const op = parsed.definitions.find( 102 + x => x.kind === Kind.OPERATION_DEFINITION 103 + ) as OperationDefinitionNode; 104 + if (!op.name) { 105 + graphQLDiagnostics.push({ 106 + message: 'Operation needs a name for types to be generated.', 107 + start: node.pos, 108 + length: originalNode.getText().length, 109 + range: {} as any, 110 + severity: 2, 111 + } as any); 112 + } 113 + } 114 + } catch (e) {} 115 + 116 + return graphQLDiagnostics; 117 + }) 118 + .flat() 119 + .filter(Boolean) as Array<Diagnostic & { length: number; start: number }>; 120 + 121 + const tsDiagnostics: ts.Diagnostic[] = diagnostics.map(diag => ({ 122 + file: source, 123 + length: diag.length, 124 + start: diag.start, 125 + category: 126 + diag.severity === 2 127 + ? ts.DiagnosticCategory.Warning 128 + : ts.DiagnosticCategory.Error, 129 + code: SEMANTIC_DIAGNOSTIC_CODE, 130 + messageText: diag.message.split('\n')[0], 131 + })); 132 + 133 + const imports = findAllImports(source); 134 + if (imports.length && shouldCheckForColocatedFragments) { 135 + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); 136 + imports.forEach(imp => { 137 + if (!imp.importClause) return; 138 + 139 + const importedNames: string[] = []; 140 + if (imp.importClause.name) { 141 + importedNames.push(imp.importClause?.name.text); 142 + } 143 + 144 + if ( 145 + imp.importClause.namedBindings && 146 + isNamespaceImport(imp.importClause.namedBindings) 147 + ) { 148 + // TODO: we might need to warn here when the fragment is unused as a namespace import 149 + return; 150 + } else if ( 151 + imp.importClause.namedBindings && 152 + isNamedImportBindings(imp.importClause.namedBindings) 153 + ) { 154 + imp.importClause.namedBindings.elements.forEach(el => { 155 + importedNames.push(el.name.text); 156 + }); 157 + } 158 + 159 + const symbol = typeChecker?.getSymbolAtLocation(imp.moduleSpecifier); 160 + if (!symbol) return; 161 + 162 + const moduleExports = typeChecker?.getExportsOfModule(symbol); 163 + if (!moduleExports) return; 164 + 165 + const missingImports = moduleExports 166 + .map(exp => { 167 + if (importedNames.includes(exp.name)) { 168 + return; 169 + } 170 + 171 + const declarations = exp.getDeclarations(); 172 + const declaration = declarations?.find(x => { 173 + // TODO: check whether the sourceFile.fileName resembles the module 174 + // specifier 175 + return true; 176 + }); 177 + 178 + if (!declaration) return; 179 + 180 + const [template] = findAllTaggedTemplateNodes(declaration); 181 + if (template) { 182 + let node = template; 183 + if ( 184 + isNoSubstitutionTemplateLiteral(node) || 185 + isTemplateExpression(node) 186 + ) { 187 + if (isTaggedTemplateExpression(node.parent)) { 188 + node = node.parent; 189 + } else { 190 + return; 191 + } 192 + } 193 + 194 + const text = resolveTemplate( 195 + node, 196 + node.getSourceFile().fileName, 197 + info 198 + ); 199 + const parsed = parse(text); 200 + if ( 201 + parsed.definitions.every(x => x.kind === Kind.FRAGMENT_DEFINITION) 202 + ) { 203 + return `'${exp.name}'`; 204 + } 205 + } 206 + }) 207 + .filter(Boolean); 208 + 209 + if (missingImports.length) { 210 + // TODO: we could use getCodeFixesAtPosition 211 + // to build on this 212 + tsDiagnostics.push({ 213 + file: source, 214 + length: imp.getText().length, 215 + start: imp.getStart(), 216 + category: ts.DiagnosticCategory.Message, 217 + code: SEMANTIC_DIAGNOSTIC_CODE, 218 + messageText: `Missing Fragment import(s) ${missingImports.join( 219 + ', ' 220 + )} from ${imp.moduleSpecifier.getText()}.`, 221 + }); 222 + } 223 + }); 224 + } 225 + 226 + if ( 227 + !tsDiagnostics.filter( 228 + x => 229 + x.category === ts.DiagnosticCategory.Error || 230 + x.category === ts.DiagnosticCategory.Warning 231 + ).length 232 + ) { 233 + try { 234 + if (isFileDirty(filename, source)) { 235 + return tsDiagnostics; 236 + } 237 + 238 + const parts = source.fileName.split('/'); 239 + const name = parts[parts.length - 1]; 240 + const nameParts = name.split('.'); 241 + nameParts[nameParts.length - 1] = 'generated.ts'; 242 + parts[parts.length - 1] = nameParts.join('.'); 243 + 244 + generateTypedDocumentNodes( 245 + schema.current, 246 + parts.join('/'), 247 + texts.join('\n'), 248 + scalars, 249 + baseTypesPath 250 + ).then(() => { 251 + if (isFileDirty(filename, source)) { 252 + return; 253 + } 254 + 255 + nodes.forEach((node, i) => { 256 + const queryText = texts[i] || ''; 257 + const parsed = parse(queryText); 258 + const isFragment = parsed.definitions.every( 259 + x => x.kind === Kind.FRAGMENT_DEFINITION 260 + ); 261 + let name = ''; 262 + 263 + if (isFragment) { 264 + const fragmentNode = parsed 265 + .definitions[0] as FragmentDefinitionNode; 266 + name = fragmentNode.name.value; 267 + } else { 268 + const operationNode = parsed 269 + .definitions[0] as OperationDefinitionNode; 270 + name = operationNode.name?.value || ''; 271 + } 272 + 273 + if (!name) return; 274 + 275 + name = name.charAt(0).toUpperCase() + name.slice(1); 276 + const parentChildren = node.parent.getChildren(); 277 + 278 + const exportName = isFragment 279 + ? `${name}FragmentDoc` 280 + : `${name}Document`; 281 + let imp = ` as typeof import('./${nameParts 282 + .join('.') 283 + .replace('.ts', '')}').${exportName}`; 284 + 285 + // This checks whether one of the children is an import-type 286 + // which is a short-circuit if there is no as 287 + const typeImport = parentChildren.find(x => 288 + isImportTypeNode(x) 289 + ) as ImportTypeNode; 290 + 291 + if (typeImport && typeImport.getText().includes(exportName)) return; 292 + 293 + const span = { length: 1, start: node.end }; 294 + 295 + let text = ''; 296 + if (typeImport) { 297 + // We only want the oldExportName here to be present 298 + // that way we can diff its length vs the new one 299 + const oldExportName = typeImport.getText().split('.').pop(); 300 + 301 + // Remove ` as ` from the beginning, 302 + // this because getText() gives us everything 303 + // but ` as ` meaning we need to keep that part 304 + // around. 305 + imp = imp.slice(4); 306 + text = source.text.replace(typeImport.getText(), imp); 307 + span.length = 308 + imp.length + ((oldExportName || '').length - exportName.length); 309 + } else { 310 + text = 311 + source.text.substring(0, span.start) + 312 + imp + 313 + source.text.substring( 314 + span.start + span.length, 315 + source.text.length 316 + ); 317 + } 318 + 319 + const scriptInfo = 320 + info.project.projectService.getScriptInfo(filename); 321 + const snapshot = scriptInfo!.getSnapshot(); 322 + 323 + source.update(text, { span, newLength: imp.length }); 324 + scriptInfo!.editContent(0, snapshot.getLength(), text); 325 + info.languageServiceHost.writeFile!(source.fileName, text); 326 + if (!!typeImport) { 327 + // To update the types, otherwise data is stale 328 + scriptInfo!.reloadFromFile(); 329 + } 330 + scriptInfo!.registerFileUpdate(); 331 + }); 332 + }); 333 + } catch (e) {} 334 + } 335 + 336 + return tsDiagnostics; 337 + }
+2 -2
packages/graphqlsp/src/getSchema.ts packages/graphqlsp/src/graphql/getSchema.ts
··· 9 9 import path from 'path'; 10 10 import fs from 'fs'; 11 11 12 - import { Logger } from './index'; 13 - import { generateBaseTypes } from './types/generate'; 12 + import { Logger } from '../index'; 13 + import { generateBaseTypes } from './generateTypes'; 14 14 15 15 export const loadSchema = ( 16 16 root: string,
+31 -472
packages/graphqlsp/src/index.ts
··· 1 1 import ts from 'typescript/lib/tsserverlibrary'; 2 - import { 3 - isNoSubstitutionTemplateLiteral, 4 - ScriptElementKind, 5 - isIdentifier, 6 - isTaggedTemplateExpression, 7 - isToken, 8 - isTemplateExpression, 9 - isImportTypeNode, 10 - ImportTypeNode, 11 - isNamespaceImport, 12 - isNamedImportBindings, 13 - } from 'typescript'; 14 - import { 15 - getHoverInformation, 16 - getAutocompleteSuggestions, 17 - getDiagnostics, 18 - Diagnostic, 19 - getTokenAtPosition, 20 - getTypeInfo, 21 - } from 'graphql-language-service'; 22 - import { 23 - parse, 24 - Kind, 25 - FragmentDefinitionNode, 26 - OperationDefinitionNode, 27 - } from 'graphql'; 28 2 29 - import { findAllImports, findAllTaggedTemplateNodes, findNode } from './ast'; 30 - import { Cursor } from './cursor'; 31 - import { loadSchema } from './getSchema'; 32 - import { getToken } from './token'; 33 - import { 34 - getSource, 35 - getSuggestionsForFragmentSpread, 36 - isFileDirty, 37 - } from './utils'; 38 - import { resolveTemplate } from './resolve'; 39 - import { generateTypedDocumentNodes } from './types/generate'; 3 + import { loadSchema } from './graphql/getSchema'; 4 + import { getGraphQLCompletions } from './autoComplete'; 5 + import { getGraphQLQuickInfo } from './quickInfo'; 6 + import { getGraphQLDiagnostics } from './diagnostics'; 40 7 41 8 function createBasicDecorator(info: ts.server.PluginCreateInfo) { 42 9 const proxy: ts.LanguageService = Object.create(null); ··· 63 30 64 31 logger('Setting up the GraphQL Plugin'); 65 32 66 - const tagTemplate = info.config.template || 'gql'; 67 33 const scalars = info.config.scalars || {}; 68 - const shouldCheckForColocatedFragments = 69 - info.config.shouldCheckForColocatedFragments || true; 70 34 71 35 const proxy = createBasicDecorator(info); 72 36 73 37 const baseTypesPath = 74 38 info.project.getCurrentDirectory() + '/__generated__/baseGraphQLSP.ts'; 39 + 75 40 const schema = loadSchema( 76 41 info.project.getProjectName(), 77 42 info.config.schema, ··· 83 48 proxy.getSemanticDiagnostics = (filename: string): ts.Diagnostic[] => { 84 49 const originalDiagnostics = 85 50 info.languageService.getSemanticDiagnostics(filename); 86 - const source = getSource(info, filename); 87 - if (!source) return originalDiagnostics; 88 - 89 - const nodes = findAllTaggedTemplateNodes(source); 90 - 91 - const texts = nodes.map(node => { 92 - if (isNoSubstitutionTemplateLiteral(node) || isTemplateExpression(node)) { 93 - if (isTaggedTemplateExpression(node.parent)) { 94 - node = node.parent; 95 - } else { 96 - return undefined; 97 - } 98 - } 99 - 100 - return resolveTemplate(node, filename, info); 101 - }); 102 - 103 - const diagnostics = nodes 104 - .map(x => { 105 - let node = x; 106 - if ( 107 - isNoSubstitutionTemplateLiteral(node) || 108 - isTemplateExpression(node) 109 - ) { 110 - if (isTaggedTemplateExpression(node.parent)) { 111 - node = node.parent; 112 - } else { 113 - return undefined; 114 - } 115 - } 116 - 117 - const text = resolveTemplate(node, filename, info); 118 - const lines = text.split('\n'); 119 - 120 - let startingPosition = node.pos + (tagTemplate.length + 1); 121 - const graphQLDiagnostics = getDiagnostics(text, schema.current).map( 122 - x => { 123 - const { start, end } = x.range; 124 - 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 - } 132 - 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 - } 138 - 139 - // We add 1 to the start because the range is exclusive of start.character 140 - return { ...x, start: startChar + 1, length: endChar - startChar }; 141 - } 142 - ); 143 - 144 - const parsed = parse(text); 145 - 146 - if ( 147 - parsed.definitions.some(x => x.kind === Kind.OPERATION_DEFINITION) 148 - ) { 149 - const op = parsed.definitions.find( 150 - x => x.kind === Kind.OPERATION_DEFINITION 151 - ) as OperationDefinitionNode; 152 - if (!op.name) { 153 - graphQLDiagnostics.push({ 154 - message: 'Operation needs a name for types to be generated.', 155 - start: node.pos, 156 - length: x.getText().length, 157 - range: {} as any, 158 - severity: 2, 159 - } as any); 160 - } 161 - } 162 - 163 - return graphQLDiagnostics; 164 - }) 165 - .flat() 166 - .filter(Boolean) as Array<Diagnostic & { length: number; start: number }>; 167 - 168 - const newDiagnostics = diagnostics.map(diag => { 169 - const result: ts.Diagnostic = { 170 - file: source, 171 - length: diag.length, 172 - start: diag.start, 173 - category: 174 - diag.severity === 2 175 - ? ts.DiagnosticCategory.Warning 176 - : ts.DiagnosticCategory.Error, 177 - code: 51001, 178 - messageText: diag.message.split('\n')[0], 179 - }; 180 - 181 - return result; 182 - }); 183 - 184 - const imports = findAllImports(source); 185 - if (imports.length && shouldCheckForColocatedFragments) { 186 - const typeChecker = info.languageService.getProgram()?.getTypeChecker(); 187 - imports.forEach(imp => { 188 - if (!imp.importClause) return; 189 - 190 - const importedNames: string[] = []; 191 - if (imp.importClause.name) { 192 - importedNames.push(imp.importClause?.name.text); 193 - } 194 - 195 - if ( 196 - imp.importClause.namedBindings && 197 - isNamespaceImport(imp.importClause.namedBindings) 198 - ) { 199 - // TODO: we might need to warn here when the fragment is unused as a namespace import 200 - return; 201 - } else if ( 202 - imp.importClause.namedBindings && 203 - isNamedImportBindings(imp.importClause.namedBindings) 204 - ) { 205 - imp.importClause.namedBindings.elements.forEach(el => { 206 - importedNames.push(el.name.text); 207 - }); 208 - } 209 - 210 - const symbol = typeChecker?.getSymbolAtLocation(imp.moduleSpecifier); 211 - if (!symbol) return; 212 - 213 - const moduleExports = typeChecker?.getExportsOfModule(symbol); 214 - if (!moduleExports) return; 215 - 216 - const missingImports = moduleExports 217 - .map(exp => { 218 - if (importedNames.includes(exp.name)) { 219 - return; 220 - } 221 - 222 - const declarations = exp.getDeclarations(); 223 - const declaration = declarations?.find(x => { 224 - // TODO: check whether the sourceFile.fileName resembles the module 225 - // specifier 226 - return true; 227 - }); 228 - 229 - if (!declaration) return; 230 - 231 - const [template] = findAllTaggedTemplateNodes(declaration); 232 - if (template) { 233 - let node = template; 234 - if ( 235 - isNoSubstitutionTemplateLiteral(node) || 236 - isTemplateExpression(node) 237 - ) { 238 - if (isTaggedTemplateExpression(node.parent)) { 239 - node = node.parent; 240 - } else { 241 - return; 242 - } 243 - } 244 - 245 - const text = resolveTemplate( 246 - node, 247 - node.getSourceFile().fileName, 248 - info 249 - ); 250 - const parsed = parse(text); 251 - if ( 252 - parsed.definitions.every( 253 - x => x.kind === Kind.FRAGMENT_DEFINITION 254 - ) 255 - ) { 256 - return `'${exp.name}'`; 257 - } 258 - } 259 - }) 260 - .filter(Boolean); 261 - 262 - if (missingImports.length) { 263 - // TODO: we could use getCodeFixesAtPosition 264 - // to build on this 265 - newDiagnostics.push({ 266 - file: source, 267 - length: imp.getText().length, 268 - start: imp.getStart(), 269 - category: ts.DiagnosticCategory.Message, 270 - code: 51001, 271 - messageText: `Missing Fragment import(s) ${missingImports.join( 272 - ', ' 273 - )} from ${imp.moduleSpecifier.getText()}.`, 274 - }); 275 - } 276 - }); 277 - } 278 - 279 - if ( 280 - !newDiagnostics.filter( 281 - x => 282 - x.category === ts.DiagnosticCategory.Error || 283 - x.category === ts.DiagnosticCategory.Warning 284 - ).length 285 - ) { 286 - try { 287 - const parts = source.fileName.split('/'); 288 - const name = parts[parts.length - 1]; 289 - const nameParts = name.split('.'); 290 - nameParts[nameParts.length - 1] = 'generated.ts'; 291 - parts[parts.length - 1] = nameParts.join('.'); 292 - 293 - if (isFileDirty(filename, source)) { 294 - return [...newDiagnostics, ...originalDiagnostics]; 295 - } 296 - 297 - generateTypedDocumentNodes( 298 - schema.current, 299 - parts.join('/'), 300 - texts.join('\n'), 301 - scalars, 302 - baseTypesPath 303 - ).then(() => { 304 - if (isFileDirty(filename, source)) { 305 - return; 306 - } 307 - 308 - nodes.forEach((node, i) => { 309 - const queryText = texts[i] || ''; 310 - const parsed = parse(queryText); 311 - const isFragment = parsed.definitions.every( 312 - x => x.kind === Kind.FRAGMENT_DEFINITION 313 - ); 314 - let name = ''; 315 - 316 - if (isFragment) { 317 - const fragmentNode = parsed 318 - .definitions[0] as FragmentDefinitionNode; 319 - name = fragmentNode.name.value; 320 - } else { 321 - const operationNode = parsed 322 - .definitions[0] as OperationDefinitionNode; 323 - name = operationNode.name?.value || ''; 324 - } 325 - 326 - if (!name) return; 327 - 328 - name = name.charAt(0).toUpperCase() + name.slice(1); 329 - const parentChildren = node.parent.getChildren(); 330 - 331 - const exportName = isFragment 332 - ? `${name}FragmentDoc` 333 - : `${name}Document`; 334 - let imp = ` as typeof import('./${nameParts 335 - .join('.') 336 - .replace('.ts', '')}').${exportName}`; 337 - 338 - // This checks whether one of the children is an import-type 339 - // which is a short-circuit if there is no as 340 - const typeImport = parentChildren.find(x => 341 - isImportTypeNode(x) 342 - ) as ImportTypeNode; 343 - 344 - if (typeImport && typeImport.getText().includes(exportName)) return; 345 - 346 - const span = { length: 1, start: node.end }; 347 - 348 - let text = ''; 349 - if (typeImport) { 350 - // We only want the oldExportName here to be present 351 - // that way we can diff its length vs the new one 352 - const oldExportName = typeImport.getText().split('.').pop(); 353 - 354 - // Remove ` as ` from the beginning, 355 - // this because getText() gives us everything 356 - // but ` as ` meaning we need to keep that part 357 - // around. 358 - imp = imp.slice(4); 359 - text = source.text.replace(typeImport.getText(), imp); 360 - span.length = 361 - imp.length + ((oldExportName || '').length - exportName.length); 362 - } else { 363 - text = 364 - source.text.substring(0, span.start) + 365 - imp + 366 - source.text.substring( 367 - span.start + span.length, 368 - source.text.length 369 - ); 370 - } 371 - 372 - const scriptInfo = 373 - info.project.projectService.getScriptInfo(filename); 374 - const snapshot = scriptInfo!.getSnapshot(); 375 - 376 - // TODO: potential optimisation is to write only one script-update 377 - source.update(text, { span, newLength: imp.length }); 378 - scriptInfo!.editContent(0, snapshot.getLength(), text); 379 - info.languageServiceHost.writeFile!(source.fileName, text); 380 - if (!!typeImport) { 381 - // To update the types, otherwise data is stale 382 - scriptInfo!.reloadFromFile(); 383 - } 384 - scriptInfo!.registerFileUpdate(); 385 - // script info contains a lot of utils that might come in handy here 386 - // to save even if the user has local changes, if we could make that work 387 - // that would be a win. If not we should check if we can figure it out through 388 - // the script-info whether there are unsaved changes and not run this 389 - // scriptInfo!.open(text); 390 - }); 391 - }); 392 - } catch (e) {} 393 - } 394 - 395 - return [...newDiagnostics, ...originalDiagnostics]; 51 + const graphQLDiagnostics = getGraphQLDiagnostics( 52 + filename, 53 + baseTypesPath, 54 + schema, 55 + info 56 + ); 57 + return graphQLDiagnostics 58 + ? [...graphQLDiagnostics, ...originalDiagnostics] 59 + : originalDiagnostics; 396 60 }; 397 61 398 62 proxy.getCompletionsAtPosition = ( ··· 411 75 entries: [], 412 76 }; 413 77 414 - const source = getSource(info, filename); 415 - if (!source) return originalCompletions; 78 + const completions = getGraphQLCompletions( 79 + filename, 80 + cursorPosition, 81 + schema, 82 + info 83 + ); 416 84 417 - let node = findNode(source, cursorPosition); 418 - if (!node) return originalCompletions; 419 - 420 - while ( 421 - isNoSubstitutionTemplateLiteral(node) || 422 - isToken(node) || 423 - isTemplateExpression(node) 424 - ) { 425 - node = node.parent; 426 - } 427 - 428 - if (isTaggedTemplateExpression(node)) { 429 - const { template, tag } = node; 430 - if (!isIdentifier(tag) || tag.text !== tagTemplate) 431 - return originalCompletions; 432 - 433 - const text = resolveTemplate(node, filename, info); 434 - const foundToken = getToken(template, cursorPosition); 435 - 436 - if (!foundToken || !schema.current) return originalCompletions; 437 - 438 - let fragments: Array<FragmentDefinitionNode> = []; 439 - try { 440 - const parsed = parse(text); 441 - fragments = parsed.definitions.filter( 442 - x => x.kind === Kind.FRAGMENT_DEFINITION 443 - ) as Array<FragmentDefinitionNode>; 444 - } catch (e) {} 445 - 446 - const cursor = new Cursor(foundToken.line, foundToken.start); 447 - const suggestions = getAutocompleteSuggestions( 448 - schema.current, 449 - text, 450 - cursor 451 - ); 452 - 453 - const token = getTokenAtPosition(text, cursor); 454 - const spreadSuggestions = getSuggestionsForFragmentSpread( 455 - token, 456 - getTypeInfo(schema.current, token.state), 457 - schema.current, 458 - text, 459 - fragments 460 - ); 461 - 462 - const result: ts.WithMetadata<ts.CompletionInfo> = { 463 - isGlobalCompletion: false, 464 - isMemberCompletion: false, 465 - isNewIdentifierLocation: false, 466 - entries: [ 467 - ...suggestions.map(suggestion => ({ 468 - ...suggestion, 469 - kind: ScriptElementKind.variableElement, 470 - name: suggestion.label, 471 - kindModifiers: 'declare', 472 - sortText: suggestion.sortText || '0', 473 - labelDetails: { 474 - detail: suggestion.type 475 - ? ' ' + suggestion.type?.toString() 476 - : undefined, 477 - description: suggestion.documentation, 478 - }, 479 - })), 480 - ...spreadSuggestions.map(suggestion => ({ 481 - ...suggestion, 482 - kind: ScriptElementKind.variableElement, 483 - name: suggestion.label, 484 - insertText: '...' + suggestion.label, 485 - kindModifiers: 'declare', 486 - sortText: '0', 487 - labelDetails: { 488 - description: suggestion.documentation, 489 - }, 490 - })), 491 - ...originalCompletions.entries, 492 - ], 85 + if (completions) { 86 + return { 87 + ...completions, 88 + entries: [...completions.entries, ...originalCompletions.entries], 493 89 }; 494 - return result; 495 90 } else { 496 91 return originalCompletions; 497 92 } ··· 503 98 cursorPosition 504 99 ); 505 100 506 - const source = getSource(info, filename); 507 - if (!source) return originalInfo; 508 - 509 - let node = findNode(source, cursorPosition); 510 - if (!node) return originalInfo; 511 - 512 - while ( 513 - isNoSubstitutionTemplateLiteral(node) || 514 - isToken(node) || 515 - isTemplateExpression(node) 516 - ) { 517 - node = node.parent; 518 - } 519 - 520 - if (isTaggedTemplateExpression(node)) { 521 - const { template, tag } = node; 522 - if (!isIdentifier(tag) || tag.text !== tagTemplate) return originalInfo; 523 - 524 - const text = resolveTemplate(node, filename, info); 525 - const foundToken = getToken(template, cursorPosition); 526 - 527 - if (!foundToken || !schema.current) return originalInfo; 101 + const quickInfo = getGraphQLQuickInfo( 102 + filename, 103 + cursorPosition, 104 + schema, 105 + info 106 + ); 528 107 529 - const hoverInfo = getHoverInformation( 530 - schema.current, 531 - text, 532 - new Cursor(foundToken.line, foundToken.start) 533 - ); 534 - const result: ts.QuickInfo = { 535 - kind: ts.ScriptElementKind.string, 536 - textSpan: { 537 - start: cursorPosition, 538 - length: 1, 539 - }, 540 - kindModifiers: '', 541 - displayParts: Array.isArray(hoverInfo) 542 - ? hoverInfo.map(item => ({ kind: '', text: item as string })) 543 - : [{ kind: '', text: hoverInfo as string }], 544 - }; 545 - 546 - return result; 547 - } else { 548 - return originalInfo; 549 - } 108 + return quickInfo || originalInfo; 550 109 }; 551 110 552 111 logger('proxy: ' + JSON.stringify(proxy));
+55
packages/graphqlsp/src/quickInfo.ts
··· 1 + import ts from 'typescript/lib/tsserverlibrary'; 2 + import { isIdentifier, isTaggedTemplateExpression } from 'typescript'; 3 + import { getHoverInformation } from 'graphql-language-service'; 4 + import { GraphQLSchema } from 'graphql'; 5 + 6 + import { bubbleUpTemplate, findNode, getSource } from './ast'; 7 + import { resolveTemplate } from './ast/resolve'; 8 + import { getToken } from './ast/token'; 9 + import { Cursor } from './ast/cursor'; 10 + 11 + export function getGraphQLQuickInfo( 12 + filename: string, 13 + cursorPosition: number, 14 + schema: { current: GraphQLSchema | null }, 15 + info: ts.server.PluginCreateInfo 16 + ): ts.QuickInfo | undefined { 17 + const tagTemplate = info.config.template || 'gql'; 18 + 19 + const source = getSource(info, filename); 20 + if (!source) return undefined; 21 + 22 + let node = findNode(source, cursorPosition); 23 + if (!node) return undefined; 24 + 25 + node = bubbleUpTemplate(node); 26 + 27 + if (isTaggedTemplateExpression(node)) { 28 + const { template, tag } = node; 29 + if (!isIdentifier(tag) || tag.text !== tagTemplate) return undefined; 30 + 31 + const foundToken = getToken(template, cursorPosition); 32 + 33 + if (!foundToken || !schema.current) return undefined; 34 + 35 + const hoverInfo = getHoverInformation( 36 + schema.current, 37 + resolveTemplate(node, filename, info), 38 + new Cursor(foundToken.line, foundToken.start) 39 + ); 40 + 41 + return { 42 + kind: ts.ScriptElementKind.string, 43 + textSpan: { 44 + start: cursorPosition, 45 + length: 1, 46 + }, 47 + kindModifiers: '', 48 + displayParts: Array.isArray(hoverInfo) 49 + ? hoverInfo.map(item => ({ kind: '', text: item as string })) 50 + : [{ kind: '', text: hoverInfo as string }], 51 + }; 52 + } else { 53 + return undefined; 54 + } 55 + }
+2 -2
packages/graphqlsp/src/resolve.ts packages/graphqlsp/src/ast/resolve.ts
··· 6 6 TaggedTemplateExpression, 7 7 } from 'typescript'; 8 8 import ts from 'typescript/lib/tsserverlibrary'; 9 - import { findNode } from './ast'; 10 - import { getSource } from './utils'; 9 + import { findNode } from '.'; 10 + import { getSource } from '../ast'; 11 11 12 12 export function resolveTemplate( 13 13 node: TaggedTemplateExpression,
packages/graphqlsp/src/token.ts packages/graphqlsp/src/ast/token.ts
packages/graphqlsp/src/types/generate.ts packages/graphqlsp/src/graphql/generateTypes.ts
+2 -21
packages/graphqlsp/src/utils.ts packages/graphqlsp/src/graphql/getFragmentSpreadSuggestions.ts
··· 1 - import ts from 'typescript/lib/tsserverlibrary'; 2 - import fs from 'fs'; 3 1 import { 4 2 CompletionItem, 5 3 CompletionItemKind, ··· 25 23 isCompositeType, 26 24 } from 'graphql'; 27 25 28 - export function isFileDirty(fileName: string, source: ts.SourceFile) { 29 - const contents = fs.readFileSync(fileName, 'utf-8'); 30 - const currentText = source.getFullText(); 31 - 32 - return currentText !== contents; 33 - } 34 - 35 - export function getSource(info: ts.server.PluginCreateInfo, filename: string) { 36 - const program = info.languageService.getProgram(); 37 - if (!program) return undefined; 38 - 39 - const source = program.getSourceFile(filename); 40 - if (!source) return undefined; 41 - 42 - return source; 43 - } 44 - 45 26 /** 46 27 * This part is vendored from https://github.com/graphql/graphiql/blob/main/packages/graphql-language-service/src/interface/autocompleteUtils.ts#L97 47 28 */ 48 - export type CompletionItemBase = { 29 + type CompletionItemBase = { 49 30 label: string; 50 31 isDeprecated?: boolean; 51 32 }; 52 33 53 34 // Create the expected hint response given a possible list and a token 54 - export function hintList<T extends CompletionItemBase>( 35 + function hintList<T extends CompletionItemBase>( 55 36 token: ContextTokenUnion, 56 37 list: Array<T> 57 38 ): Array<T> {
+11
test/e2e/fixture-project/fixtures/Post.ts
··· 1 + import { gql } from "@urql/core"; 2 + 3 + export const PostFields = gql` 4 + fragment postFields on Post { 5 + title 6 + } 7 + ` 8 + 9 + export const Post = (post: any) => { 10 + return post.title 11 + }
+12
test/e2e/fixture-project/fixtures/Posts.ts
··· 1 + import { gql } from "@urql/core"; 2 + import { Post } from "./Post"; 3 + 4 + const PostsQuery = gql` 5 + query PostsList { 6 + posts { 7 + id 8 + } 9 + } 10 + ` 11 + 12 + Post({ title: '' })
+133
test/e2e/fragments.test.ts
··· 1 + import { expect, afterAll, beforeAll, it, describe } from 'vitest'; 2 + import { TSServer } from './server'; 3 + import path from 'node:path'; 4 + import fs from 'node:fs'; 5 + import url from 'node:url'; 6 + import ts from 'typescript/lib/tsserverlibrary'; 7 + import { waitForExpect } from './util'; 8 + 9 + const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); 10 + 11 + const projectPath = path.resolve(__dirname, 'fixture-project'); 12 + describe('Fragments', () => { 13 + const outFilePost = path.join(projectPath, 'Post.ts'); 14 + const outFilePosts = path.join(projectPath, 'Posts.ts'); 15 + const genFilePost = path.join(projectPath, 'Post.generated.ts'); 16 + const genFilePosts = path.join(projectPath, 'Posts.generated.ts'); 17 + const baseGenFile = path.join(projectPath, '__generated__/baseGraphQLSP.ts'); 18 + 19 + let server: TSServer; 20 + beforeAll(async () => { 21 + server = new TSServer(projectPath, { debugLog: false }); 22 + }); 23 + 24 + afterAll(() => { 25 + try { 26 + fs.unlinkSync(outFilePost); 27 + fs.unlinkSync(outFilePosts); 28 + fs.unlinkSync(genFilePost); 29 + fs.unlinkSync(genFilePosts); 30 + fs.unlinkSync(baseGenFile); 31 + } catch {} 32 + }); 33 + 34 + it('should send a message for missing fragment import', async () => { 35 + server.sendCommand('open', { 36 + file: outFilePost, 37 + fileContent: '// empty', 38 + scriptKindName: 'TS', 39 + } satisfies ts.server.protocol.OpenRequestArgs); 40 + 41 + server.sendCommand('open', { 42 + file: outFilePosts, 43 + fileContent: '// empty', 44 + scriptKindName: 'TS', 45 + } satisfies ts.server.protocol.OpenRequestArgs); 46 + 47 + server.sendCommand('updateOpen', { 48 + openFiles: [ 49 + { 50 + file: outFilePosts, 51 + fileContent: fs.readFileSync( 52 + path.join(projectPath, 'fixtures/Posts.ts'), 53 + 'utf-8' 54 + ), 55 + }, 56 + { 57 + file: outFilePost, 58 + fileContent: fs.readFileSync( 59 + path.join(projectPath, 'fixtures/Post.ts'), 60 + 'utf-8' 61 + ), 62 + }, 63 + ], 64 + } satisfies ts.server.protocol.UpdateOpenRequestArgs); 65 + 66 + server.sendCommand('saveto', { 67 + file: outFilePost, 68 + tmpfile: outFilePost, 69 + } satisfies ts.server.protocol.SavetoRequestArgs); 70 + 71 + server.sendCommand('saveto', { 72 + file: outFilePosts, 73 + tmpfile: outFilePosts, 74 + } satisfies ts.server.protocol.SavetoRequestArgs); 75 + 76 + await waitForExpect(() => { 77 + expect(fs.readFileSync(outFilePosts, 'utf-8')).toContain( 78 + `as typeof import('./Posts.generated').PostsListDocument` 79 + ); 80 + }); 81 + 82 + await waitForExpect(() => { 83 + const generatedPostsFileContents = fs.readFileSync(genFilePosts, 'utf-8'); 84 + expect(generatedPostsFileContents).toContain( 85 + 'export const PostsListDocument = ' 86 + ); 87 + expect(generatedPostsFileContents).toContain( 88 + 'import * as Types from "./__generated__/baseGraphQLSP"' 89 + ); 90 + }); 91 + 92 + await waitForExpect(() => { 93 + expect(fs.readFileSync(outFilePost, 'utf-8')).toContain( 94 + `as typeof import('./Post.generated').PostFieldsFragmentDoc` 95 + ); 96 + }); 97 + 98 + await waitForExpect(() => { 99 + const generatedPostFileContents = fs.readFileSync(genFilePost, 'utf-8'); 100 + expect(generatedPostFileContents).toContain( 101 + 'export const PostFieldsFragmentDoc = ' 102 + ); 103 + expect(generatedPostFileContents).toContain( 104 + 'import * as Types from "./__generated__/baseGraphQLSP"' 105 + ); 106 + }); 107 + 108 + const res = server.responses 109 + .reverse() 110 + .find( 111 + resp => 112 + resp.type === 'event' && 113 + resp.event === 'semanticDiag' && 114 + resp.body.file === outFilePosts 115 + ); 116 + 117 + expect(res?.body.diagnostics).toEqual([ 118 + { 119 + category: 'message', 120 + code: 51001, 121 + end: { 122 + line: 2, 123 + offset: 31, 124 + }, 125 + start: { 126 + line: 2, 127 + offset: 1, 128 + }, 129 + text: 'Missing Fragment import(s) \'PostFields\' from "./Post".', 130 + }, 131 + ]); 132 + }, 30000); 133 + });
-19
test/e2e/graphqlsp.test.ts
··· 55 55 server.close(); 56 56 }); 57 57 58 - it('Generates types for a given query', async () => { 59 - expect(() => { 60 - fs.lstatSync(testFile); 61 - fs.lstatSync(generatedFile); 62 - fs.lstatSync(baseGeneratedFile); 63 - }).not.toThrow(); 64 - 65 - const testFileContents = fs.readFileSync(testFile, 'utf-8'); 66 - const generatedFileContents = fs.readFileSync(generatedFile, 'utf-8'); 67 - 68 - expect(testFileContents).toContain( 69 - `as typeof import('./simple.generated').AllPostsDocument` 70 - ); 71 - expect(generatedFileContents).toContain('export const AllPostsDocument = '); 72 - expect(generatedFileContents).toContain( 73 - 'import * as Types from "./__generated__/baseGraphQLSP"' 74 - ); 75 - }, 7500); 76 - 77 58 it('Proposes suggestions for a selection-set', async () => { 78 59 server.send({ 79 60 seq: 8,
+15 -2
test/e2e/rename.test.ts test/e2e/generate-types.test.ts
··· 9 9 const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); 10 10 11 11 const projectPath = path.resolve(__dirname, 'fixture-project'); 12 - describe('Operation name', () => { 12 + describe('Type-generation', () => { 13 13 const outFile = path.join(projectPath, 'rename.ts'); 14 14 const genFile = path.join(projectPath, 'rename.generated.ts'); 15 + const baseGenFile = path.join(projectPath, '__generated__/baseGraphQLSP.ts'); 15 16 16 17 let server: TSServer; 17 18 beforeAll(async () => { ··· 22 23 try { 23 24 fs.unlinkSync(outFile); 24 25 fs.unlinkSync(genFile); 26 + fs.unlinkSync(baseGenFile); 25 27 } catch {} 26 28 }); 27 29 ··· 52 54 await waitForExpect(() => { 53 55 expect(fs.readFileSync(outFile, 'utf-8')).toContain( 54 56 `as typeof import('./rename.generated').PostsDocument` 57 + ); 58 + const generatedFileContents = fs.readFileSync(genFile, 'utf-8'); 59 + expect(generatedFileContents).toContain('export const PostsDocument = '); 60 + expect(generatedFileContents).toContain( 61 + 'import * as Types from "./__generated__/baseGraphQLSP"' 55 62 ); 56 63 }); 57 64 65 + expect(() => { 66 + fs.lstatSync(outFile); 67 + fs.lstatSync(genFile); 68 + fs.lstatSync(baseGenFile); 69 + }).not.toThrow(); 70 + 58 71 server.sendCommand('updateOpen', { 59 72 openFiles: [ 60 73 { ··· 79 92 'export const PostListDocument =' 80 93 ); 81 94 }); 82 - }, 12500); 95 + }, 20000); 83 96 });