diff --git a/packages/codemirror-graphql/README.md b/packages/codemirror-graphql/README.md index 0959bf1402d..df66d0596b9 100644 --- a/packages/codemirror-graphql/README.md +++ b/packages/codemirror-graphql/README.md @@ -19,6 +19,8 @@ CodeMirror helpers install themselves to the global CodeMirror when they are imported. ```js +import type { ValidationContext, SDLValidationContext } from 'graphql'; + import CodeMirror from 'codemirror'; import 'codemirror/addon/hint/show-hint'; import 'codemirror/addon/lint/lint'; @@ -30,6 +32,83 @@ CodeMirror.fromTextArea(myTextarea, { mode: 'graphql', lint: { schema: myGraphQLSchema, + validationRules: [ExampleRule], + }, + hintOptions: { + schema: myGraphQLSchema, + }, +}); +``` + +## External Fragments Example + +If you want to have autcompletion for external fragment definitions, there's a new configuration setting available + +```ts +import CodeMirror from 'codemirror'; +import 'codemirror/addon/hint/show-hint'; +import 'codemirror/addon/lint/lint'; +import 'codemirror-graphql/hint'; +import 'codemirror-graphql/lint'; +import 'codemirror-graphql/mode'; + +const externalFragments = ` + fragment MyFragment on Example { + id: ID! + name: String! + } + fragment AnotherFragment on Example { + id: ID! + title: String! + } +`; + +CodeMirror.fromTextArea(myTextarea, { + mode: 'graphql', + lint: { + schema: myGraphQLSchema, + }, + hintOptions: { + schema: myGraphQLSchema, + // here we use a string, but + // you can also provide an array of FragmentDefinitionNodes + externalFragments, + }, +}); +``` + +### Custom Validation Rules + +If you want to show custom validation, you can do that too! It uses the `ValidationRule` interface. + +```js +import type { ValidationRule } from 'graphql'; + +import CodeMirror from 'codemirror'; +import 'codemirror/addon/hint/show-hint'; +import 'codemirror/addon/lint/lint'; +import 'codemirror-graphql/hint'; +import 'codemirror-graphql/lint'; +import 'codemirror-graphql/mode'; + +const ExampleRule: ValidationRule = context => { + // your custom rules here + const schema = context.getSchema(); + const document = context.getDocument(); + return { + NamedType(node) { + if (node.name.value !== node.name.value.toLowercase()) { + context.reportError('only lowercase type names allowed!'); + } + }, + }; +}; + +CodeMirror.fromTextArea(myTextarea, { + mode: 'graphql', + lint: { + schema: myGraphQLSchema, + validationRules: [ExampleRule], }, hintOptions: { schema: myGraphQLSchema, diff --git a/packages/codemirror-graphql/src/__tests__/hint-test.js b/packages/codemirror-graphql/src/__tests__/hint-test.js index ed8595dc1eb..aa35ebadecf 100644 --- a/packages/codemirror-graphql/src/__tests__/hint-test.js +++ b/packages/codemirror-graphql/src/__tests__/hint-test.js @@ -40,6 +40,7 @@ function createEditorWithHint() { schema: TestSchema, closeOnUnfocus: false, completeSingle: false, + externalFragments: 'fragment Example on Test { id }', }, }); } @@ -766,6 +767,11 @@ describe('graphql-hint', () => { type: TestType, description: 'fragment Foo on Test', }, + { + text: 'Example', + type: TestType, + description: 'fragment Example on Test', + }, ]; const expectedSuggestions = getExpectedSuggestions(list); expect(suggestions.list).to.deep.equal(expectedSuggestions); @@ -782,6 +788,11 @@ describe('graphql-hint', () => { type: TestType, description: 'fragment Foo on Test', }, + { + text: 'Example', + type: TestType, + description: 'fragment Example on Test', + }, ]; const expectedSuggestions = getExpectedSuggestions(list); expect(suggestions.list).to.deep.equal(expectedSuggestions); diff --git a/packages/codemirror-graphql/src/hint.js b/packages/codemirror-graphql/src/hint.js index abeebdad5bc..a78d32918a7 100644 --- a/packages/codemirror-graphql/src/hint.js +++ b/packages/codemirror-graphql/src/hint.js @@ -13,7 +13,7 @@ import CodeMirror from 'codemirror'; import { getAutocompleteSuggestions } from 'graphql-language-service-interface'; import { Position } from 'graphql-language-service-utils'; - +import { getFragmentDefinitions } from './utils/getFragmentDefinitions'; /** * Registers a "hint" helper for CodeMirror. * @@ -52,6 +52,9 @@ CodeMirror.registerHelper('hint', 'graphql', (editor, options) => { editor.getValue(), position, token, + Array.isArray(options.externalFragments) + ? options.externalFragments + : getFragmentDefinitions(options.externalFragments), ); const results = { diff --git a/packages/codemirror-graphql/src/lint.js b/packages/codemirror-graphql/src/lint.js index 7a1b06eab01..cbf3eb617ef 100644 --- a/packages/codemirror-graphql/src/lint.js +++ b/packages/codemirror-graphql/src/lint.js @@ -33,8 +33,7 @@ const TYPE = { */ CodeMirror.registerHelper('lint', 'graphql', (text, options) => { const schema = options.schema; - const validationRules = options.validationRules; - const rawResults = getDiagnostics(text, schema, validationRules); + const rawResults = getDiagnostics(text, schema, options.validationRules); const results = rawResults.map(error => ({ message: error.message, diff --git a/packages/codemirror-graphql/src/utils/getFragmentDefinitions.js b/packages/codemirror-graphql/src/utils/getFragmentDefinitions.js new file mode 100644 index 00000000000..066ec3b12b3 --- /dev/null +++ b/packages/codemirror-graphql/src/utils/getFragmentDefinitions.js @@ -0,0 +1,12 @@ +import { visit, parse } from 'graphql'; +import type { FragmentDefinitionNode } from 'graphql'; + +export function getFragmentDefinitions(graphqlString: string) { + const definitions: FragmentDefinitionNode[] = []; + visit(parse(graphqlString), { + FragmentDefinition(node) { + definitions.push(node); + }, + }); + return definitions; +} diff --git a/packages/graphiql-2-rfc-context/src/api/hooks/useQueryFacts.ts b/packages/graphiql-2-rfc-context/src/api/hooks/useQueryFacts.ts index 04583bbd3b3..a478f29ae82 100644 --- a/packages/graphiql-2-rfc-context/src/api/hooks/useQueryFacts.ts +++ b/packages/graphiql-2-rfc-context/src/api/hooks/useQueryFacts.ts @@ -8,14 +8,14 @@ import { useMemo } from 'react'; -import getQueryFacts from '../../utility/getQueryFacts'; +import getOperationFacts from '../../utility/getQueryFacts'; import useSchema from './useSchema'; import useOperation from './useOperation'; export default function useQueryFacts() { const schema = useSchema(); const { text } = useOperation(); - return useMemo(() => (schema ? getQueryFacts(schema, text) : null), [ + return useMemo(() => (schema ? getOperationFacts(schema, text) : null), [ schema, text, ]); diff --git a/packages/graphiql-2-rfc-context/src/utility/getQueryFacts.ts b/packages/graphiql-2-rfc-context/src/utility/getQueryFacts.ts index edf7bd74817..e49119c5554 100644 --- a/packages/graphiql-2-rfc-context/src/utility/getQueryFacts.ts +++ b/packages/graphiql-2-rfc-context/src/utility/getQueryFacts.ts @@ -13,6 +13,7 @@ import { OperationDefinitionNode, NamedTypeNode, GraphQLNamedType, + Kind, } from 'graphql'; export type VariableToType = { @@ -30,7 +31,7 @@ export type QueryFacts = { * * If the query cannot be parsed, returns undefined. */ -export default function getQueryFacts( +export default function getOperationFacts( schema?: GraphQLSchema, documentStr?: string | null, ): QueryFacts | undefined { @@ -52,7 +53,7 @@ export default function getQueryFacts( // Collect operations by their names. const operations: OperationDefinitionNode[] = []; documentAST.definitions.forEach(def => { - if (def.kind === 'OperationDefinition') { + if (def.kind === Kind.OPERATION_DEFINITION) { operations.push(def); } }); @@ -71,7 +72,7 @@ export function collectVariables( [variable: string]: GraphQLNamedType; } = Object.create(null); documentAST.definitions.forEach(definition => { - if (definition.kind === 'OperationDefinition') { + if (definition.kind === Kind.OPERATION_DEFINITION) { const variableDefinitions = definition.variableDefinitions; if (variableDefinitions) { variableDefinitions.forEach(({ variable, type }) => { diff --git a/packages/graphiql/README.md b/packages/graphiql/README.md index 893ddfda7fc..0737c8dcb48 100644 --- a/packages/graphiql/README.md +++ b/packages/graphiql/README.md @@ -178,7 +178,8 @@ For more details on props, see the [API Docs](https://graphiql-test.netlify.app/ | `query` | `string` (GraphQL) | initial displayed query, if `undefined` is provided, the stored query or `defaultQuery` will be used. You can also set this value at runtime to override the current operation editor state. | | `validationRules` | `ValidationRule[]` | A array of validation rules that will be used for validating the GraphQL operations. If `undefined` is provided, the default rules (exported as `specifiedRules` from `graphql`) will be used. | | `variables` | `string` (JSON) | initial displayed query variables, if `undefined` is provided, the stored variables will be used. | -| `headers` | `string` (JSON) | initial displayed request headers. if not defined, it will default to the stored headers if `shouldPersistHeaders` is enabled. | +| `headers` | `string` | initial displayed request headers. if not defined, it will default to the stored headers if `shouldPersistHeaders` is enabled. | +| `externalFragments` | `string | FragmentDefinitionNode[]` | provide fragments external to the operation for completion, validation, and for selective use when executing operations. | | `operationName` | `string` | an optional name of which GraphQL operation should be executed. | | `response` | `string` (JSON) | an optional JSON string to use as the initial displayed response. If not provided, no response will be initially shown. You might provide this if illustrating the result of the initial query. | | `storage` | [`Storage`](https://graphiql-test.netlify.app/typedoc/interfaces/graphiql.storage.html) | **Default:** `window.localStorage`. an interface that matches `window.localStorage` signature that GraphiQL will use to persist state. | diff --git a/packages/graphiql/package.json b/packages/graphiql/package.json index dd63a34bb11..dbfc5cfeacb 100644 --- a/packages/graphiql/package.json +++ b/packages/graphiql/package.json @@ -45,6 +45,7 @@ "codemirror": "^5.54.0", "codemirror-graphql": "^0.14.0", "copy-to-clipboard": "^3.2.0", + "graphql-language-service": "^3.0.2", "entities": "^2.0.0", "markdown-it": "^10.0.0" }, diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index fb9080f0007..55376bdc6f8 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -17,12 +17,16 @@ import { GraphQLSchema, parse, print, + visit, OperationDefinitionNode, IntrospectionQuery, GraphQLType, ValidationRule, + FragmentDefinitionNode, + DocumentNode, } from 'graphql'; import copyToClipboard from 'copy-to-clipboard'; +import { getFragmentDependenciesForAST } from 'graphql-language-service'; import { ExecuteButton } from './ExecuteButton'; import { ImagePreview } from './ImagePreview'; @@ -37,7 +41,7 @@ import { DocExplorer } from './DocExplorer'; import { QueryHistory } from './QueryHistory'; import CodeMirrorSizer from '../utility/CodeMirrorSizer'; import StorageAPI, { Storage } from '../utility/StorageAPI'; -import getQueryFacts, { VariableToType } from '../utility/getQueryFacts'; +import getOperationFacts, { VariableToType } from '../utility/getQueryFacts'; import getSelectedOperationName from '../utility/getSelectedOperationName'; import debounce from '../utility/debounce'; import find from '../utility/find'; @@ -79,6 +83,7 @@ export type FetcherParams = { export type FetcherOpts = { headers?: { [key: string]: any }; shouldPersistHeaders: boolean; + documentAST?: DocumentNode; }; export type FetcherResult = @@ -122,8 +127,9 @@ export type GraphiQLProps = { defaultSecondaryEditorOpen?: boolean; headerEditorEnabled?: boolean; shouldPersistHeaders?: boolean; + externalFragments?: string | FragmentDefinitionNode[]; onCopyQuery?: (query?: string) => void; - onEditQuery?: (query?: string) => void; + onEditQuery?: (query?: string, documentAST?: DocumentNode) => void; onEditVariables?: (value: string) => void; onEditHeaders?: (value: string) => void; onEditOperationName?: (operationName: string) => void; @@ -158,6 +164,7 @@ export type GraphiQLState = { subscription?: Unsubscribable | null; variableToType?: VariableToType; operations?: OperationDefinitionNode[]; + documentAST?: DocumentNode; }; /** @@ -225,8 +232,7 @@ export class GraphiQL extends React.Component { : defaultQuery; // Get the initial query facts. - const queryFacts = getQueryFacts(props.schema, query); - + const queryFacts = getOperationFacts(props.schema, query); // Determine the initial variables to display. const variables = props.variables !== undefined @@ -558,6 +564,7 @@ export class GraphiQL extends React.Component { onRunQuery={this.handleEditorRunQuery} editorTheme={this.props.editorTheme} readOnly={this.props.readOnly} + externalFragments={this.props.externalFragments} />
{ const fetcherOpts: FetcherOpts = { shouldPersistHeaders: Boolean(this.props.shouldPersistHeaders), + documentAST: this.state.documentAST, }; if (this.state.headers && this.state.headers.trim().length > 2) { fetcherOpts.headers = JSON.parse(this.state.headers); @@ -870,7 +878,7 @@ export class GraphiQL extends React.Component { if (typeof result !== 'string' && 'data' in result) { const schema = buildClientSchema(result.data); - const queryFacts = getQueryFacts(schema, this.state.query); + const queryFacts = getOperationFacts(schema, this.state.query); this.safeSetState({ schema, ...queryFacts }); } else { const responseString = @@ -923,6 +931,38 @@ export class GraphiQL extends React.Component { if (typeof jsonHeaders !== 'object') { throw new Error('Headers are not a JSON object.'); } + // TODO: memoize this + if (this.props.externalFragments) { + const externalFragments = new Map(); + + if (Array.isArray(this.props.externalFragments)) { + this.props.externalFragments.forEach(def => { + externalFragments.set(def.name.value, def); + }); + } else { + visit( + parse(this.props.externalFragments, { + experimentalFragmentVariables: true, + }), + { + FragmentDefinition(def) { + externalFragments.set(def.name.value, def); + }, + }, + ); + } + const fragmentDependencies = getFragmentDependenciesForAST( + this.state.documentAST!, + externalFragments, + ); + if (fragmentDependencies.length > 0) { + query += + '\n' + + fragmentDependencies + .map((node: FragmentDefinitionNode) => print(node)) + .join('\n'); + } + } const fetch = fetcher( { @@ -930,7 +970,11 @@ export class GraphiQL extends React.Component { variables: jsonVariables, operationName, }, - { headers: jsonHeaders, shouldPersistHeaders }, + { + headers: jsonHeaders, + shouldPersistHeaders, + documentAST: this.state.documentAST, + }, ); if (isPromise(fetch)) { @@ -1114,7 +1158,9 @@ export class GraphiQL extends React.Component { handlePrettifyQuery = () => { const editor = this.getQueryEditor(); const editorContent = editor?.getValue() ?? ''; - const prettifiedEditorContent = print(parse(editorContent)); + const prettifiedEditorContent = print( + parse(editorContent, { experimentalFragmentVariables: true }), + ); if (prettifiedEditorContent !== editorContent) { editor?.setValue(prettifiedEditorContent); @@ -1161,7 +1207,7 @@ export class GraphiQL extends React.Component { return; } - const ast = parse(query); + const ast = this.state.documentAST!; editor.setValue(print(mergeAST(ast, this.state.schema))); }; @@ -1178,7 +1224,7 @@ export class GraphiQL extends React.Component { }); this._storage.set('query', value); if (this.props.onEditQuery) { - return this.props.onEditQuery(value); + return this.props.onEditQuery(value, queryFacts?.documentAST); } }); @@ -1203,7 +1249,7 @@ export class GraphiQL extends React.Component { prevOperations?: OperationDefinitionNode[], schema?: GraphQLSchema, ) => { - const queryFacts = getQueryFacts(schema, query); + const queryFacts = getOperationFacts(schema, query); if (queryFacts) { // Update operation name should any query names change. const updatedOperationName = getSelectedOperationName( diff --git a/packages/graphiql/src/components/QueryEditor.tsx b/packages/graphiql/src/components/QueryEditor.tsx index 2c6cdae8ae7..d6eda44c4c5 100644 --- a/packages/graphiql/src/components/QueryEditor.tsx +++ b/packages/graphiql/src/components/QueryEditor.tsx @@ -7,7 +7,12 @@ import React from 'react'; import type * as CM from 'codemirror'; -import { GraphQLSchema, GraphQLType, ValidationRule } from 'graphql'; +import { + FragmentDefinitionNode, + GraphQLSchema, + GraphQLType, + ValidationRule, +} from 'graphql'; import MD from 'markdown-it'; import { normalizeWhitespace } from '../utility/normalizeWhitespace'; import onHasCompletion from '../utility/onHasCompletion'; @@ -30,6 +35,7 @@ type QueryEditorProps = { onMergeQuery?: () => void; onRunQuery?: () => void; editorTheme?: string; + externalFragments?: string | FragmentDefinitionNode[]; }; /** @@ -101,12 +107,15 @@ export class QueryEditor extends React.Component lint: { schema: this.props.schema, validationRules: this.props.validationRules ?? null, + // linting accepts string or FragmentDefinitionNode[] + externalFragments: this.props?.externalFragments, }, hintOptions: { schema: this.props.schema, closeOnUnfocus: false, completeSingle: false, container: this._node, + externalFragments: this.props?.externalFragments, }, info: { schema: this.props.schema, diff --git a/packages/graphiql/src/utility/getQueryFacts.ts b/packages/graphiql/src/utility/getQueryFacts.ts index edf7bd74817..068db3c7768 100644 --- a/packages/graphiql/src/utility/getQueryFacts.ts +++ b/packages/graphiql/src/utility/getQueryFacts.ts @@ -13,6 +13,7 @@ import { OperationDefinitionNode, NamedTypeNode, GraphQLNamedType, + visit, } from 'graphql'; export type VariableToType = { @@ -22,15 +23,16 @@ export type VariableToType = { export type QueryFacts = { variableToType?: VariableToType; operations?: OperationDefinitionNode[]; + documentAST?: DocumentNode; }; /** - * Provided previous "queryFacts", a GraphQL schema, and a query document + * Provided previous "operationFacts", a GraphQL schema, and a query document * string, return a set of facts about that query useful for GraphiQL features. * * If the query cannot be parsed, returns undefined. */ -export default function getQueryFacts( +export default function getOperationFacts( schema?: GraphQLSchema, documentStr?: string | null, ): QueryFacts | undefined { @@ -40,7 +42,9 @@ export default function getQueryFacts( let documentAST: DocumentNode; try { - documentAST = parse(documentStr); + documentAST = parse(documentStr, { + experimentalFragmentVariables: true, + }); } catch { return; } @@ -51,15 +55,21 @@ export default function getQueryFacts( // Collect operations by their names. const operations: OperationDefinitionNode[] = []; - documentAST.definitions.forEach(def => { - if (def.kind === 'OperationDefinition') { - operations.push(def); - } + + visit(documentAST, { + OperationDefinition(node) { + operations.push(node); + }, }); - return { variableToType, operations }; + return { variableToType, operations, documentAST }; } +/** + * as a nod to folks who were clever enough to import this utility on their + */ +export const getQueryFacts = getOperationFacts; + /** * Provided a schema and a document, produces a `variableToType` Object. */ diff --git a/packages/graphiql/tsconfig.esm.json b/packages/graphiql/tsconfig.esm.json index aea6efbc174..a041dec59dc 100644 --- a/packages/graphiql/tsconfig.esm.json +++ b/packages/graphiql/tsconfig.esm.json @@ -18,5 +18,13 @@ "**/*-test.js", "**/*.stories.js", "**/*.stories.ts" + ], + "references": [ + { + "path": "../graphql-language-service" + }, + { + "path": "../graphql-language-service-utils" + } ] } diff --git a/packages/graphiql/tsconfig.json b/packages/graphiql/tsconfig.json index 458ba460e72..5974e5e74ea 100644 --- a/packages/graphiql/tsconfig.json +++ b/packages/graphiql/tsconfig.json @@ -19,5 +19,13 @@ "**/*.stories.js", "**/*.stories.ts", "**/*.stories.tsx" + ], + "references": [ + { + "path": "../graphql-language-service" + }, + { + "path": "../graphql-language-service-utils" + } ] } diff --git a/packages/graphql-language-service-interface/README.md b/packages/graphql-language-service-interface/README.md index fa5d829d8ed..6c6a78a04d5 100644 --- a/packages/graphql-language-service-interface/README.md +++ b/packages/graphql-language-service-interface/README.md @@ -6,6 +6,8 @@ ![Snyk Vulnerabilities for npm package](https://img.shields.io/snyk/vulnerabilities/npm/codemirror-graphql) [![License](https://img.shields.io/npm/l/graphql-language-service-interface.svg?style=flat-square)](LICENSE) +[API Docs](https://graphiql-test.netlify.app/typedoc/modules/graphql_language_service_interface.html) + LSP Language Service written in Typescript used by [GraphQL Language Service Server](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service-server) and [Codemirror GraphQL](https://github.com/graphql/graphiql/tree/main/packages/codemirror-graphql). This provides the Official [Language Server Protocol](https://langserver.org) compliant GraphQL language service to be used by an IDE plugin, a browser application or desktop application. diff --git a/packages/graphql-language-service-interface/src/GraphQLLanguageService.ts b/packages/graphql-language-service-interface/src/GraphQLLanguageService.ts index 9b15322edf6..fe52782a3ee 100644 --- a/packages/graphql-language-service-interface/src/GraphQLLanguageService.ts +++ b/packages/graphql-language-service-interface/src/GraphQLLanguageService.ts @@ -18,10 +18,9 @@ import { import { CompletionItem, - DefinitionQueryResult, Diagnostic, Uri, - Position, + IPosition, Outline, OutlineTree, GraphQLCache, @@ -42,6 +41,7 @@ import { getDefinitionQueryResultForFragmentSpread, getDefinitionQueryResultForDefinitionNode, getDefinitionQueryResultForNamedType, + DefinitionQueryResult, } from './getDefinition'; import { getOutline } from './getOutline'; @@ -221,7 +221,7 @@ export class GraphQLLanguageService { public async getAutocompleteSuggestions( query: string, - position: Position, + position: IPosition, filePath: Uri, ): Promise> { const projectConfig = this.getConfigForURI(filePath); @@ -231,7 +231,7 @@ export class GraphQLLanguageService { ); const fragmentInfo = Array.from(fragmentDefinitions).map( - ([, info]) => info, + ([, info]) => info.definition, ); if (schema) { @@ -240,7 +240,7 @@ export class GraphQLLanguageService { query, position, undefined, - fragmentInfo.map(({ definition }) => definition), + fragmentInfo, ); } return []; @@ -248,7 +248,7 @@ export class GraphQLLanguageService { public async getHoverInformation( query: string, - position: Position, + position: IPosition, filePath: Uri, ): Promise { const projectConfig = this.getConfigForURI(filePath); @@ -262,7 +262,7 @@ export class GraphQLLanguageService { public async getDefinition( query: string, - position: Position, + position: IPosition, filePath: Uri, ): Promise { const projectConfig = this.getConfigForURI(filePath); diff --git a/packages/graphql-language-service-interface/src/__tests__/getAutocompleteSuggestions-test.ts b/packages/graphql-language-service-interface/src/__tests__/getAutocompleteSuggestions-test.ts index 1e0a64ebdde..18802d50525 100644 --- a/packages/graphql-language-service-interface/src/__tests__/getAutocompleteSuggestions-test.ts +++ b/packages/graphql-language-service-interface/src/__tests__/getAutocompleteSuggestions-test.ts @@ -10,7 +10,12 @@ import { CompletionItem } from 'graphql-language-service-types'; import fs from 'fs'; -import { buildSchema, GraphQLSchema } from 'graphql'; +import { + buildSchema, + FragmentDefinitionNode, + GraphQLSchema, + parse, +} from 'graphql'; import { Position } from 'graphql-language-service-utils'; import path from 'path'; @@ -31,8 +36,15 @@ describe('getAutocompleteSuggestions', () => { function testSuggestions( query: string, point: Position, + externalFragments?: FragmentDefinitionNode[], ): Array { - return getAutocompleteSuggestions(schema, query, point) + return getAutocompleteSuggestions( + schema, + query, + point, + null, + externalFragments, + ) .filter( field => !['__schema', '__type'].some(name => name === field.label), ) @@ -314,6 +326,28 @@ query name { ).toEqual([{ label: 'Foo', detail: 'Human' }]); }); + it('provides correct fragment name suggestions for external fragments', () => { + const externalFragments = parse(` + fragment CharacterDetails on Human { + name + } + fragment CharacterDetails2 on Human { + name + } + `).definitions as FragmentDefinitionNode[]; + + const result = testSuggestions( + 'query { human(id: "1") { ... }}', + new Position(0, 28), + externalFragments, + ); + + expect(result).toEqual([ + { label: 'CharacterDetails', detail: 'Human' }, + { label: 'CharacterDetails2', detail: 'Human' }, + ]); + }); + it('provides correct directive suggestions', () => { expect(testSuggestions('{ test @ }', new Position(0, 8))).toEqual([ { label: 'include' }, diff --git a/packages/graphql-language-service-interface/src/__tests__/getDiagnostics-test.ts b/packages/graphql-language-service-interface/src/__tests__/getDiagnostics-test.ts index 62a877dc9be..15c39e2893d 100644 --- a/packages/graphql-language-service-interface/src/__tests__/getDiagnostics-test.ts +++ b/packages/graphql-language-service-interface/src/__tests__/getDiagnostics-test.ts @@ -16,6 +16,7 @@ import { GraphQLError, ValidationContext, ASTVisitor, + FragmentDefinitionNode, } from 'graphql'; import path from 'path'; @@ -136,4 +137,33 @@ describe('getDiagnostics', () => { expect(errors).toHaveLength(1); expect(errors[0].message).toEqual('No query allowed.'); }); + + it('validates with external fragments', () => { + const errors = getDiagnostics( + `query hero { hero { ...HeroGuy } }`, + schema, + [], + false, + 'fragment HeroGuy on Human { id }', + ); + expect(errors).toHaveLength(0); + }); + it('validates with external fragments as array', () => { + const externalFragments = parse(` + fragment Person on Human { + name + } + fragment Person2 on Human { + name + } + `).definitions as FragmentDefinitionNode[]; + const errors = getDiagnostics( + `query hero { hero { ...Person ...Person2 } }`, + schema, + [], + false, + externalFragments, + ); + expect(errors).toHaveLength(0); + }); }); diff --git a/packages/graphql-language-service-interface/src/getAutocompleteSuggestions.ts b/packages/graphql-language-service-interface/src/getAutocompleteSuggestions.ts index 3481f27bdf0..4c0527c7d18 100644 --- a/packages/graphql-language-service-interface/src/getAutocompleteSuggestions.ts +++ b/packages/graphql-language-service-interface/src/getAutocompleteSuggestions.ts @@ -26,7 +26,7 @@ import { import { CompletionItem, AllTypeInfo, - Position, + IPosition, } from 'graphql-language-service-types'; import { @@ -70,7 +70,7 @@ import { export function getAutocompleteSuggestions( schema: GraphQLSchema, queryText: string, - cursor: Position, + cursor: IPosition, contextToken?: ContextToken, fragmentDefs?: FragmentDefinitionNode[], ): Array { @@ -493,7 +493,7 @@ function getSuggestionsForFragmentSpread( const defState = getDefinitionState(token.state); const fragments = getFragmentDefinitions(queryText); - if (fragmentDefs) { + if (fragmentDefs && fragmentDefs.length > 0) { fragments.push(...fragmentDefs); } @@ -665,7 +665,7 @@ function getSuggestionsForDirective( export function getTokenAtPosition( queryText: string, - cursor: Position, + cursor: IPosition, ): ContextToken { let styleAtCursor = null; let stateAtCursor = null; diff --git a/packages/graphql-language-service-interface/src/getDefinition.ts b/packages/graphql-language-service-interface/src/getDefinition.ts index 47955a685be..9298fd897de 100644 --- a/packages/graphql-language-service-interface/src/getDefinition.ts +++ b/packages/graphql-language-service-interface/src/getDefinition.ts @@ -19,15 +19,22 @@ import { import { Definition, - DefinitionQueryResult, FragmentInfo, - Position, - Range, Uri, ObjectTypeInfo, } from 'graphql-language-service-types'; -import { locToRange, offsetToPosition } from 'graphql-language-service-utils'; +import { + locToRange, + offsetToPosition, + Range, + Position, +} from 'graphql-language-service-utils'; + +export type DefinitionQueryResult = { + queryRange: Range[]; + definitions: Definition[]; +}; export const LANGUAGE = 'GraphQL'; @@ -40,7 +47,7 @@ function assert(value: any, message: string) { function getRange(text: string, node: ASTNode): Range { const location = node.loc as Location; assert(location, 'Expected ASTNode to have a location.'); - return locToRange(text, location); + return locToRange(text, location) as Range; } function getPosition(text: string, node: ASTNode): Position { diff --git a/packages/graphql-language-service-interface/src/getDiagnostics.ts b/packages/graphql-language-service-interface/src/getDiagnostics.ts index bd55bbf407e..681da408e44 100644 --- a/packages/graphql-language-service-interface/src/getDiagnostics.ts +++ b/packages/graphql-language-service-interface/src/getDiagnostics.ts @@ -10,11 +10,13 @@ import { ASTNode, DocumentNode, + FragmentDefinitionNode, GraphQLError, GraphQLSchema, Location, SourceLocation, ValidationRule, + print, } from 'graphql'; import { findDeprecatedUsages, parse } from 'graphql'; @@ -29,6 +31,8 @@ import { import { DiagnosticSeverity, Diagnostic } from 'vscode-languageserver-types'; +import { IRange } from 'graphql-language-service-types'; + // this doesn't work without the 'as', kinda goofy export const SEVERITY = { @@ -60,8 +64,22 @@ export function getDiagnostics( schema: GraphQLSchema | null | undefined = null, customRules?: Array, isRelayCompatMode?: boolean, + externalFragments?: FragmentDefinitionNode[] | string, ): Array { let ast = null; + if (externalFragments) { + if (typeof externalFragments === 'string') { + query += '\n\n' + externalFragments; + } else { + query += + '\n\n' + + externalFragments.reduce((agg, node) => { + agg += print(node) + '\n\n'; + return agg; + }, ''); + } + } + try { ast = parse(query); } catch (error) { @@ -152,7 +170,7 @@ function annotations( return highlightedNodes; } -export function getRange(location: SourceLocation, queryText: string): Range { +export function getRange(location: SourceLocation, queryText: string): IRange { const parser = onlineParser(); const state = parser.startState(); const lines = queryText.split('\n'); diff --git a/packages/graphql-language-service-interface/src/getHoverInformation.ts b/packages/graphql-language-service-interface/src/getHoverInformation.ts index 0a2e3568b5d..516063d5eb8 100644 --- a/packages/graphql-language-service-interface/src/getHoverInformation.ts +++ b/packages/graphql-language-service-interface/src/getHoverInformation.ts @@ -21,7 +21,7 @@ import { GraphQLFieldConfig, } from 'graphql'; import { ContextToken } from 'graphql-language-service-parser'; -import { AllTypeInfo, Position } from 'graphql-language-service-types'; +import { AllTypeInfo, IPosition } from 'graphql-language-service-types'; import { Hover } from 'vscode-languageserver-types'; import { getTokenAtPosition, getTypeInfo } from './getAutocompleteSuggestions'; @@ -29,7 +29,7 @@ import { getTokenAtPosition, getTypeInfo } from './getAutocompleteSuggestions'; export function getHoverInformation( schema: GraphQLSchema, queryText: string, - cursor: Position, + cursor: IPosition, contextToken?: ContextToken, ): Hover['contents'] { const token = contextToken || getTokenAtPosition(queryText, cursor); diff --git a/packages/graphql-language-service-interface/src/getOutline.ts b/packages/graphql-language-service-interface/src/getOutline.ts index 6cb63738681..5bc35f3d63e 100644 --- a/packages/graphql-language-service-interface/src/getOutline.ts +++ b/packages/graphql-language-service-interface/src/getOutline.ts @@ -7,7 +7,12 @@ * */ -import { Outline, TextToken, TokenKind } from 'graphql-language-service-types'; +import { + Outline, + TextToken, + TokenKind, + IPosition, +} from 'graphql-language-service-types'; import { Kind, @@ -30,7 +35,7 @@ import { FieldDefinitionNode, EnumValueDefinitionNode, } from 'graphql'; -import { offsetToPosition, Position } from 'graphql-language-service-utils'; +import { offsetToPosition } from 'graphql-language-service-utils'; const { INLINE_FRAGMENT } = Kind; @@ -59,8 +64,8 @@ export type OutlineableKinds = keyof typeof OUTLINEABLE_KINDS; type OutlineTreeResult = | { representativeName: string; - startPosition: Position; - endPosition: Position; + startPosition: IPosition; + endPosition: IPosition; children: SelectionSetNode[] | []; tokenizedText: TextToken[]; } diff --git a/packages/graphql-language-service-interface/src/index.js.flow b/packages/graphql-language-service-interface/src/index.js.flow index 06ae609c1be..209c32f20ca 100644 --- a/packages/graphql-language-service-interface/src/index.js.flow +++ b/packages/graphql-language-service-interface/src/index.js.flow @@ -13,7 +13,7 @@ import type { Diagnostic, ContextToken, ContextTokenUnion, - Position, + IPosition, DefinitionQueryResult, Uri, GraphQLCache, @@ -33,19 +33,23 @@ declare export function getDiagnostics( schema: ?GraphQLSchema, customRules?: Array, isRelayCompatMode?: boolean, + externalFragments?: Array | string ): Array; + declare export function getAutocompleteSuggestions( schema: GraphQLSchema, queryText: string, - cursor: Position, + cursor: IPosition, contextToken?: ContextToken, + externalFragments?: Array ): Array; + declare export class GraphQLLanguageService { constructor(cache: GraphQLCache): GraphQLLanguageService; getDefinition( query: string, - position: Position, + position: IPosition, filePath: Uri, ): Promise; @@ -57,13 +61,13 @@ declare export class GraphQLLanguageService { getHoverInformation( query: string, - position: Position, + position: IPosition, filePath: Uri, ): Promise; getAutocompleteSuggestions( query: string, - position: Position, + position: IPosition, filePath: Uri, ): Promise>; } diff --git a/packages/graphql-language-service-parser/README.md b/packages/graphql-language-service-parser/README.md index f16c48eea3e..4b75121c0ad 100644 --- a/packages/graphql-language-service-parser/README.md +++ b/packages/graphql-language-service-parser/README.md @@ -4,4 +4,6 @@ ![npm downloads](https://img.shields.io/npm/dm/graphql-language-service-parser?label=npm%20downloads) [![License](https://img.shields.io/npm/l/graphql-language-service-parser.svg?style=flat-square)](LICENSE) +[API Docs](https://graphiql-test.netlify.app/typedoc/modules/graphql_language_service_parser.html) + An online immutable parser for [GraphQL](http://graphql.org/), designed to be used as part of syntax-highlighting and code intelligence tools such as for the [GraphQL Language Service](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service) and [codemirror-graphql](https://github.com/graphql/graphiql/tree/main/packages/codemirror-graphql). diff --git a/packages/graphql-language-service-server/README.md b/packages/graphql-language-service-server/README.md index 1fe065a43e5..34ab9b0e187 100644 --- a/packages/graphql-language-service-server/README.md +++ b/packages/graphql-language-service-server/README.md @@ -4,6 +4,8 @@ ![npm downloads](https://img.shields.io/npm/dm/graphql-language-service-server?label=npm%20downloads) [![License](https://img.shields.io/npm/l/graphql-language-service-server.svg?style=flat-square)](LICENSE) +[API Docs](https://graphiql-test.netlify.app/typedoc/modules/graphql_language_service_server.html) + Server process backing the [GraphQL Language Service](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service). GraphQL Language Service Server provides an interface for building GraphQL language services for IDEs. diff --git a/packages/graphql-language-service-server/src/MessageProcessor.ts b/packages/graphql-language-service-server/src/MessageProcessor.ts index 26a1d3e27fe..476177a331a 100644 --- a/packages/graphql-language-service-server/src/MessageProcessor.ts +++ b/packages/graphql-language-service-server/src/MessageProcessor.ts @@ -16,15 +16,13 @@ import { Uri, GraphQLConfig, GraphQLProjectConfig, -} from 'graphql-language-service'; - -import { GraphQLLanguageService, FileChangeTypeKind, + Range, + Position, + IPosition, } from 'graphql-language-service'; -import { Range, Position } from 'graphql-language-service-utils'; - import type { CompletionParams, FileEvent, @@ -48,6 +46,7 @@ import type { DidChangeWatchedFilesParams, InitializeParams, Range as RangeType, + Position as VscodePosition, TextDocumentPositionParams, DocumentSymbolParams, SymbolInformation, @@ -77,6 +76,9 @@ type CachedDocumentType = { version: number; contents: CachedContent[]; }; +function toPosition(position: VscodePosition): IPosition { + return new Position(position.line, position.character); +} export class MessageProcessor { _connection: IConnection; @@ -471,7 +473,7 @@ export class MessageProcessor { const found = cachedDocument.contents.find(content => { const currentRange = content.range; - if (currentRange && currentRange.containsPosition(position)) { + if (currentRange && currentRange.containsPosition(toPosition(position))) { return true; } }); @@ -488,7 +490,7 @@ export class MessageProcessor { } const result = await this._languageService.getAutocompleteSuggestions( query, - position, + toPosition(position), textDocument.uri, ); @@ -523,7 +525,7 @@ export class MessageProcessor { const found = cachedDocument.contents.find(content => { const currentRange = content.range; - if (currentRange && currentRange.containsPosition(position)) { + if (currentRange && currentRange.containsPosition(toPosition(position))) { return true; } }); @@ -540,7 +542,7 @@ export class MessageProcessor { } const result = await this._languageService.getHoverInformation( query, - position, + toPosition(position), textDocument.uri, ); @@ -651,7 +653,7 @@ export class MessageProcessor { const found = cachedDocument.contents.find(content => { const currentRange = content.range; - if (currentRange && currentRange.containsPosition(position)) { + if (currentRange && currentRange.containsPosition(toPosition(position))) { return true; } }); @@ -671,7 +673,7 @@ export class MessageProcessor { try { result = await this._languageService.getDefinition( query, - position, + toPosition(position), textDocument.uri, ); } catch (err) { diff --git a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts index 7b918e5835a..436da8543b8 100644 --- a/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts +++ b/packages/graphql-language-service-server/src/__tests__/MessageProcessor-test.ts @@ -83,8 +83,8 @@ describe('MessageProcessor', () => { { representativeName: 'item', kind: 'Field', - startPosition: { line: 1, character: 2 }, - endPosition: { line: 1, character: 4 }, + startPosition: new Position(1, 2), + endPosition: new Position(1, 4), children: [], }, ], diff --git a/packages/graphql-language-service-types/README.md b/packages/graphql-language-service-types/README.md index f440365aef1..71b6c25c13b 100644 --- a/packages/graphql-language-service-types/README.md +++ b/packages/graphql-language-service-types/README.md @@ -4,4 +4,6 @@ ![npm downloads](https://img.shields.io/npm/dm/graphql-language-service-types?label=npm%20downloads) [![License](https://img.shields.io/npm/l/graphql-language-service-types.svg?style=flat-square)](LICENSE) -[Flow](https://flowtype.org/) type definitions for the [GraphQL Language Service](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service). +[API Docs](https://graphiql-test.netlify.app/typedoc/modules/graphql_language_service_types.html) + +[Typescript](https://typescript.com) and [Flow](https://flowtype.org/) type definitions for the [GraphQL Language Service](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service) and other parts of the language ecosystem. diff --git a/packages/graphql-language-service-types/src/index.js.flow b/packages/graphql-language-service-types/src/index.js.flow index 5cd597da323..7fe8348ac0e 100644 --- a/packages/graphql-language-service-types/src/index.js.flow +++ b/packages/graphql-language-service-types/src/index.js.flow @@ -148,16 +148,16 @@ export interface GraphQLCache { } // online-parser related -export interface Position { +export interface IPosition { line: number; character: number; - lessThanOrEqualTo: (position: Position) => boolean; + lessThanOrEqualTo: (position: IPosition) => boolean; } -export interface Range { - start: Position; - end: Position; - containsPosition: (position: Position) => boolean; +export interface IRange { + start: IPosition; + end: IPosition; + containsPosition: (position: IPosition) => boolean; } export type CachedContent = { diff --git a/packages/graphql-language-service-types/src/index.ts b/packages/graphql-language-service-types/src/index.ts index d472322a5a3..0fb2f1e9455 100644 --- a/packages/graphql-language-service-types/src/index.ts +++ b/packages/graphql-language-service-types/src/index.ts @@ -107,21 +107,24 @@ export interface GraphQLCache { } // online-parser related -export type Position = { +export interface IPosition { line: number; character: number; - lessThanOrEqualTo?: (position: Position) => boolean; -}; - -export interface Range { - start: Position; - end: Position; - containsPosition: (position: Position) => boolean; + setLine(line: number): void; + setCharacter(character: number): void; + lessThanOrEqualTo(position: IPosition): boolean; } +export interface IRange { + start: IPosition; + end: IPosition; + setEnd(line: number, character: number): void; + setStart(line: number, character: number): void; + containsPosition(position: IPosition): boolean; +} export type CachedContent = { query: string; - range: Range | null; + range: IRange | null; }; // GraphQL Language Service related types @@ -192,19 +195,14 @@ export type CompletionItem = CompletionItemType & { // Definitions/hyperlink export type Definition = { path: Uri; - position: Position; - range?: Range; + position: IPosition; + range?: IRange; id?: string; name?: string; language?: string; projectRoot?: Uri; }; -export type DefinitionQueryResult = { - queryRange: Range[]; - definitions: Definition[]; -}; - // Outline view export type TokenKind = | 'keyword' @@ -228,8 +226,8 @@ export type OutlineTree = { tokenizedText?: TokenizedText; representativeName?: string; kind: string; - startPosition: Position; - endPosition?: Position; + startPosition: IPosition; + endPosition?: IPosition; children: OutlineTree[]; }; diff --git a/packages/graphql-language-service-utils/README.md b/packages/graphql-language-service-utils/README.md index 22d28fe2bef..30e4b44f64d 100644 --- a/packages/graphql-language-service-utils/README.md +++ b/packages/graphql-language-service-utils/README.md @@ -4,4 +4,6 @@ ![npm downloads](https://img.shields.io/npm/dm/graphql-language-service-utils?label=npm%20downloads) [![License](https://img.shields.io/npm/l/graphql-language-service-utils.svg?style=flat-square)](LICENSE) -Utilities to support the [GraphQL Language Service](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service). +[API Docs](https://graphiql-test.netlify.app/typedoc/modules/graphql_language_service_utils.html) + +Utilities to support the [GraphQL Language Service](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service) and the rest of the language ecosystem. diff --git a/packages/graphql-language-service-utils/src/Range.ts b/packages/graphql-language-service-utils/src/Range.ts index 473a3e6a676..d36c36e2914 100644 --- a/packages/graphql-language-service-utils/src/Range.ts +++ b/packages/graphql-language-service-utils/src/Range.ts @@ -8,15 +8,12 @@ */ import { Location } from 'graphql/language'; -import { - Range as RangeInterface, - Position as PositionInterface, -} from 'graphql-language-service-types'; +import { IRange, IPosition } from 'graphql-language-service-types'; -export class Range implements RangeInterface { - start: PositionInterface; - end: PositionInterface; - constructor(start: PositionInterface, end: PositionInterface) { +export class Range implements IRange { + start: IPosition; + end: IPosition; + constructor(start: IPosition, end: IPosition) { this.start = start; this.end = end; } @@ -29,7 +26,7 @@ export class Range implements RangeInterface { this.end = new Position(line, character); } - containsPosition = (position: PositionInterface): boolean => { + containsPosition = (position: IPosition): boolean => { if (this.start.line === position.line) { return this.start.character <= position.character; } else if (this.end.line === position.line) { @@ -40,7 +37,7 @@ export class Range implements RangeInterface { }; } -export class Position implements PositionInterface { +export class Position implements IPosition { line: number; character: number; constructor(line: number, character: number) { @@ -56,7 +53,7 @@ export class Position implements PositionInterface { this.character = character; } - lessThanOrEqualTo = (position: PositionInterface): boolean => + lessThanOrEqualTo = (position: IPosition): boolean => this.line < position.line || (this.line === position.line && this.character <= position.character); } diff --git a/packages/graphql-language-service-utils/src/__tests__/Range-test.ts b/packages/graphql-language-service-utils/src/__tests__/Range-test.ts index 5d60b7e05ee..128d406f354 100644 --- a/packages/graphql-language-service-utils/src/__tests__/Range-test.ts +++ b/packages/graphql-language-service-utils/src/__tests__/Range-test.ts @@ -29,14 +29,14 @@ const offsetRangeStart = new Position(1, 2); const offsetRangeEnd = new Position(1, 5); describe('Position', () => { - it('constructs a Position object', () => { + it('constructs a IPosition object', () => { const pos = new Position(3, 5); expect(pos).not.toBeUndefined(); expect(pos.character).toEqual(5); expect(pos.line).toEqual(3); }); - it('compares Position objects', () => { + it('compares IPosition objects', () => { const posA = new Position(1, 2); const posB = new Position(2, 2); const posC = new Position(2, 3); @@ -57,7 +57,7 @@ describe('Range', () => { range = new Range(start, end); }); - it('constructs a Range object', () => { + it('constructs a IRange object', () => { expect(range).not.toBeUndefined(); expect(range.start).toEqual(start); expect(range.end).toEqual(end); diff --git a/packages/graphql-language-service-utils/src/fragmentDependencies.ts b/packages/graphql-language-service-utils/src/fragmentDependencies.ts new file mode 100644 index 00000000000..a0a7ff0949f --- /dev/null +++ b/packages/graphql-language-service-utils/src/fragmentDependencies.ts @@ -0,0 +1,76 @@ +import { DocumentNode, FragmentDefinitionNode, parse, visit } from 'graphql'; +import nullthrows from 'nullthrows'; + +export const getFragmentDependencies = ( + operationString: string, + fragmentDefinitions?: Map | null, +): FragmentDefinitionNode[] => { + // If there isn't context for fragment references, + // return an empty array. + if (!fragmentDefinitions) { + return []; + } + // If the operation cannot be parsed, validations cannot happen yet. + // Return an empty array. + let parsedOperation; + try { + parsedOperation = parse(operationString, { + allowLegacySDLImplementsInterfaces: true, + allowLegacySDLEmptyFields: true, + }); + } catch (error) { + return []; + } + return getFragmentDependenciesForAST(parsedOperation, fragmentDefinitions); +}; + +export const getFragmentDependenciesForAST = ( + parsedOperation: DocumentNode, + fragmentDefinitions: Map, +): FragmentDefinitionNode[] => { + if (!fragmentDefinitions) { + return []; + } + + const existingFrags = new Map(); + const referencedFragNames = new Set(); + + visit(parsedOperation, { + FragmentDefinition(node) { + existingFrags.set(node.name.value, true); + }, + FragmentSpread(node) { + if (!referencedFragNames.has(node.name.value)) { + referencedFragNames.add(node.name.value); + } + }, + }); + + const asts = new Set(); + referencedFragNames.forEach(name => { + if (!existingFrags.has(name) && fragmentDefinitions.has(name)) { + asts.add(nullthrows(fragmentDefinitions.get(name))); + } + }); + + const referencedFragments: FragmentDefinitionNode[] = []; + + asts.forEach(ast => { + visit(ast, { + FragmentSpread(node) { + if ( + !referencedFragNames.has(node.name.value) && + fragmentDefinitions.get(node.name.value) + ) { + asts.add(nullthrows(fragmentDefinitions.get(node.name.value))); + referencedFragNames.add(node.name.value); + } + }, + }); + if (!existingFrags.has(ast.name.value)) { + referencedFragments.push(ast); + } + }); + + return referencedFragments; +}; diff --git a/packages/graphql-language-service-utils/src/getASTNodeAtPosition.ts b/packages/graphql-language-service-utils/src/getASTNodeAtPosition.ts index 4246b59fac1..d306436c867 100644 --- a/packages/graphql-language-service-utils/src/getASTNodeAtPosition.ts +++ b/packages/graphql-language-service-utils/src/getASTNodeAtPosition.ts @@ -9,7 +9,7 @@ import { ASTNode } from 'graphql/language'; -import { Position as TPosition } from 'graphql-language-service-types'; +import { IPosition as TPosition } from 'graphql-language-service-types'; import { visit } from 'graphql'; export function getASTNodeAtPosition( diff --git a/packages/graphql-language-service-utils/src/index.js.flow b/packages/graphql-language-service-utils/src/index.js.flow index f80080eade0..53432cad92e 100644 --- a/packages/graphql-language-service-utils/src/index.js.flow +++ b/packages/graphql-language-service-utils/src/index.js.flow @@ -9,22 +9,25 @@ */ import type { Location } from 'graphql/language'; + import type { - Range as RangeInterface, - Position as PositionInterface, + IRange, + IPosition , } from 'graphql-language-service-types'; declare export function getASTNodeAtPosition(): void; -declare export class Position implements PositionInterface { + +declare export class Position implements IPosition { line: number; character: number; - constructor(row: number, column: number): Position; - lessThanOrEqualTo: (position: PositionInterface) => boolean; + constructor(row: number, column: number): IPosition; + lessThanOrEqualTo: (position: IPosition) => boolean; } -declare export class Range implements RangeInterface { - start: PositionInterface; - end: PositionInterface; - constructor(start: Position, end: Position): Range; - containsPosition: (position: PositionInterface) => boolean; + +declare export class Range implements IRange { + start: IPosition; + end: IPosition; + constructor(start: IPosition, end: IPosition): IRange; + containsPosition: (position: IPosition) => boolean; } diff --git a/packages/graphql-language-service-utils/src/index.ts b/packages/graphql-language-service-utils/src/index.ts index 11e7d6e676f..6667d4a367d 100644 --- a/packages/graphql-language-service-utils/src/index.ts +++ b/packages/graphql-language-service-utils/src/index.ts @@ -7,6 +7,11 @@ * */ +export { + getFragmentDependencies, + getFragmentDependenciesForAST, +} from './fragmentDependencies'; + export { getASTNodeAtPosition, pointToOffset } from './getASTNodeAtPosition'; export { Position, Range, locToRange, offsetToPosition } from './Range'; diff --git a/packages/graphql-language-service/README.md b/packages/graphql-language-service/README.md index 75380f964a8..db2b3f24913 100644 --- a/packages/graphql-language-service/README.md +++ b/packages/graphql-language-service/README.md @@ -1,5 +1,7 @@ # `graphql-language-service` +[API Docs](https://graphiql-test.netlify.app/typedoc/modules/graphql_language_service.html) + > **Note**: Still mostly experimental, however it depends mostly on stable libraries. > **Migration Note**: As of 3.0.0, the LSP command line interface has been moved to [`graphql-language-service-cli`](../graphql-language-service-cli) diff --git a/packages/graphql-language-service/src/LanguageService.ts b/packages/graphql-language-service/src/LanguageService.ts index f7f53d3a922..90e84c5fb02 100644 --- a/packages/graphql-language-service/src/LanguageService.ts +++ b/packages/graphql-language-service/src/LanguageService.ts @@ -4,8 +4,15 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -import { parse, GraphQLSchema, ParseOptions, ValidationRule } from 'graphql'; -import type { Position } from 'graphql-language-service-types'; +import { + parse, + GraphQLSchema, + ParseOptions, + ValidationRule, + FragmentDefinitionNode, + visit, +} from 'graphql'; +import type { IPosition } from 'graphql-language-service-types'; import { getAutocompleteSuggestions, getDiagnostics, @@ -26,6 +33,7 @@ export type GraphQLLanguageConfig = { schemaString?: string; parseOptions?: ParseOptions; schemaConfig: SchemaConfig; + exteralFragmentDefinitions?: FragmentDefinitionNode[] | string; }; export class LanguageService { @@ -39,6 +47,10 @@ export class LanguageService { private _schemaBuilder = defaultSchemaBuilder; private _schemaString: string | null = null; private _parseOptions: ParseOptions | undefined = undefined; + private _exteralFragmentDefinitionNodes: + | FragmentDefinitionNode[] + | null = null; + private _exteralFragmentDefinitionsString: string | null = null; constructor({ parser, schemaLoader, @@ -46,6 +58,7 @@ export class LanguageService { schemaConfig, schemaString, parseOptions, + exteralFragmentDefinitions, }: GraphQLLanguageConfig) { this._schemaConfig = schemaConfig; if (parser) { @@ -63,6 +76,13 @@ export class LanguageService { if (parseOptions) { this._parseOptions = parseOptions; } + if (exteralFragmentDefinitions) { + if (Array.isArray(exteralFragmentDefinitions)) { + this._exteralFragmentDefinitionNodes = exteralFragmentDefinitions; + } else { + this._exteralFragmentDefinitionsString = exteralFragmentDefinitions; + } + } } public get schema() { @@ -76,6 +96,31 @@ export class LanguageService { return this.loadSchema(); } + public async getExternalFragmentDefinitions(): Promise< + FragmentDefinitionNode[] + > { + if ( + !this._exteralFragmentDefinitionNodes && + this._exteralFragmentDefinitionsString + ) { + const definitionNodes: FragmentDefinitionNode[] = []; + try { + visit(await this._parser(this._exteralFragmentDefinitionsString), { + FragmentDefinition(node) { + definitionNodes.push(node); + }, + }); + } catch (err) { + throw Error( + `Failed parsing exteralFragmentDefinitions string:\n${this._exteralFragmentDefinitionsString}`, + ); + } + + this._exteralFragmentDefinitionNodes = definitionNodes; + } + return this._exteralFragmentDefinitionNodes as FragmentDefinitionNode[]; + } + /** * setSchema statically, ignoring URI * @param schema {schemaString} @@ -127,13 +172,19 @@ export class LanguageService { public getCompletion = async ( _uri: string, documentText: string, - position: Position, + position: IPosition, ) => { const schema = await this.getSchema(); if (!documentText || documentText.length < 1 || !schema) { return []; } - return getAutocompleteSuggestions(schema, documentText, position); + return getAutocompleteSuggestions( + schema, + documentText, + position, + undefined, + await this.getExternalFragmentDefinitions(), + ); }; public getDiagnostics = async ( @@ -151,7 +202,7 @@ export class LanguageService { public getHover = async ( _uri: string, documentText: string, - position: Position, + position: IPosition, ) => getHoverInformation( (await this.getSchema()) as GraphQLSchema, diff --git a/packages/graphql-language-service/src/index.ts b/packages/graphql-language-service/src/index.ts index 1506c5a0da4..1d2e0ca0180 100644 --- a/packages/graphql-language-service/src/index.ts +++ b/packages/graphql-language-service/src/index.ts @@ -12,3 +12,4 @@ export * from './LanguageService'; export * from 'graphql-language-service-interface'; export * from 'graphql-language-service-parser'; export * from 'graphql-language-service-types'; +export * from 'graphql-language-service-utils'; diff --git a/packages/monaco-graphql/README.md b/packages/monaco-graphql/README.md index 27e35c44e9d..867b1af3981 100644 --- a/packages/monaco-graphql/README.md +++ b/packages/monaco-graphql/README.md @@ -2,10 +2,13 @@ GraphQL language plugin for the Monaco Editor. You can use it to build vscode/codespaces-like web or desktop IDEs using whatever frontend javascript libraries or frameworks you want, or none! -- [webpack example](../../examples/monaco-graphql-webpack/) using plain javascript -- [live demo](https://monaco-graphql.netlify.com)of the monaco webpack example +- [webpack example](https://github.com/graphql/graphiql/tree/main/examples/monaco-graphql-webpack/) using plain javascript +- [graphiql 2.x RFC example](https://github.com/graphql/graphiql/tree/main/packages/graphiql-2-rfc-context/) using react 16 +- [live demo](https://monaco-graphql.netlify.com) of the monaco webpack example (prompts for github access token!) -> **NOTE:** This is in pre-release state. Helping out with this project will help advance GraphiQL and many other GraphQL IDE projects. `codemirror-graphql` has more features, such as JSON variables validation, and is more stable. +> **NOTE:** This is in pre-release state as we build towards GraphiQL 2.0.x. [`codemirror-graphql`](https://github.com/graphql/graphiql/tree/main/packages/codemirror-graphql) has more features (such as JSON variables validation) and is more stable. + +## Features It provides the following features while editing GraphQL files: @@ -14,8 +17,9 @@ It provides the following features while editing GraphQL files: - Validation (schema driven) - Formatting - using prettier - Syntax Highlighting -- Configurable schema loading (or custom) +- Configurable schema loading (or custom) - only handles a single schema currently - Configurable formatting options +- Providing external fragments ## Usage @@ -138,6 +142,12 @@ GraphQLAPI.setFormattingOptions({ }); ``` +#### `GraphQLAPI.setExternalFragmentDefintions()` + +Append external fragments to be used by autocomplete and other language features. + +This accepts either a string that contains fragment definitions, or `TypeDefinitionNode[]` + #### `GraphQLAPI.getSchema()` Returns either an AST `DocumentNode` or `IntrospectionQuery` result json using default or provided `schemaLoader` diff --git a/packages/monaco-graphql/src/api.ts b/packages/monaco-graphql/src/api.ts index b275299dc9f..905f343cdb6 100644 --- a/packages/monaco-graphql/src/api.ts +++ b/packages/monaco-graphql/src/api.ts @@ -12,7 +12,12 @@ import type { WorkerAccessor } from './languageFeatures'; import type { IEvent } from 'monaco-editor'; import { Emitter } from 'monaco-editor'; -import { DocumentNode, GraphQLSchema, printSchema } from 'graphql'; +import { + DocumentNode, + FragmentDefinitionNode, + GraphQLSchema, + printSchema, +} from 'graphql'; export type LanguageServiceAPIOptions = { languageId: string; @@ -31,6 +36,10 @@ export class LanguageServiceAPI { private _workerPromise: Promise; private _resolveWorkerPromise: (value: WorkerAccessor) => void = () => {}; private _schemaString: string | null = null; + private _externalFragmentDefinitions: + | string + | FragmentDefinitionNode[] + | null = null; constructor({ languageId, @@ -48,6 +57,7 @@ export class LanguageServiceAPI { } this.setModeConfiguration(modeConfiguration); this.setFormattingOptions(formattingOptions); + this.setFormattingOptions(formattingOptions); } public get onDidChange(): IEvent { return this._onDidChange.event; @@ -65,6 +75,9 @@ export class LanguageServiceAPI { public get formattingOptions(): FormattingOptions { return this._formattingOptions; } + public get externalFragmentDefinitions() { + return this._externalFragmentDefinitions; + } public get hasSchema() { return Boolean(this._schemaString); } @@ -113,10 +126,15 @@ export class LanguageServiceAPI { public updateSchemaConfig(options: Partial): void { this._schemaConfig = { ...this._schemaConfig, ...options }; - this._onDidChange.fire(this); } + public setExternalFragmentDefinitions( + externalFragmentDefinitions: string | FragmentDefinitionNode[], + ) { + this._externalFragmentDefinitions = externalFragmentDefinitions; + } + public setSchemaUri(schemaUri: string): void { this.setSchemaConfig({ ...this._schemaConfig, uri: schemaUri }); } diff --git a/packages/monaco-graphql/src/utils.ts b/packages/monaco-graphql/src/utils.ts index 4095668bbb8..59f020d54c4 100644 --- a/packages/monaco-graphql/src/utils.ts +++ b/packages/monaco-graphql/src/utils.ts @@ -6,11 +6,13 @@ */ import type { - Range as GraphQLRange, - Position as GraphQLPosition, + IRange as GraphQLRange, + IPosition as GraphQLPosition, Diagnostic, CompletionItem as GraphQLCompletionItem, -} from 'graphql-language-service-types'; +} from 'graphql-language-service'; + +import { Position } from 'graphql-language-service'; // @ts-ignore export type MonacoCompletionItem = monaco.languages.CompletionItem & { @@ -26,9 +28,10 @@ export function toMonacoRange(range: GraphQLRange): monaco.IRange { endColumn: range.end.character + 1, }; } + // @ts-ignore export function toGraphQLPosition(position: monaco.Position): GraphQLPosition { - return { line: position.lineNumber - 1, character: position.column - 1 }; + return new Position(position.lineNumber - 1, position.column - 1); } export function toCompletion( diff --git a/packages/monaco-graphql/src/workerManager.ts b/packages/monaco-graphql/src/workerManager.ts index b9c8934f4b8..25c06c2a8fa 100644 --- a/packages/monaco-graphql/src/workerManager.ts +++ b/packages/monaco-graphql/src/workerManager.ts @@ -78,6 +78,8 @@ export class WorkerManager { languageConfig: { schemaString: this._defaults.schemaString, schemaConfig: this._defaults.schemaConfig, + exteralFragmentDefinitions: this._defaults + .externalFragmentDefinitions, }, } as ICreateData, }); diff --git a/resources/tsconfig.build.esm.json b/resources/tsconfig.build.esm.json index f9406e160c4..e39c8ea8876 100644 --- a/resources/tsconfig.build.esm.json +++ b/resources/tsconfig.build.esm.json @@ -28,6 +28,9 @@ }, { "path": "../packages/graphql-language-service-server/tsconfig.esm.json" + }, + { + "path": "../packages/graphql-language-service-cli/tsconfig.esm.json" } ] } diff --git a/yarn.lock b/yarn.lock index e62c018acf4..0f2f7c62793 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12632,12 +12632,13 @@ grapheme-breaker@^0.3.2: unicode-trie "^0.3.1" "graphiql@file:packages/graphiql": - version "1.2.1" + version "1.2.2" dependencies: codemirror "^5.54.0" - codemirror-graphql "^0.13.1" + codemirror-graphql "^0.14.0" copy-to-clipboard "^3.2.0" entities "^2.0.0" + graphql-language-service "^3.0.2" markdown-it "^10.0.0" graphql-config@^3.0.2, graphql-config@^3.0.3: @@ -16553,10 +16554,10 @@ monaco-editor@^0.20.0: integrity sha512-hkvf4EtPJRMQlPC3UbMoRs0vTAFAYdzFQ+gpMb8A+9znae1c43q8Mab9iVsgTcg/4PNiLGGn3SlDIa8uvK1FIQ== "monaco-graphql@file:packages/monaco-graphql": - version "0.3.4" + version "0.3.5" dependencies: - graphql-language-service "^3.0.5" - graphql-language-service-utils "^2.4.3" + graphql-language-service "^3.0.6" + graphql-language-service-utils "^2.4.4" monaco-editor "^0.20.0" move-concurrently@^1.0.1: