diff --git a/.changeset/quick-info-upgrades.md b/.changeset/quick-info-upgrades.md new file mode 100644 index 0000000..ba10345 --- /dev/null +++ b/.changeset/quick-info-upgrades.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Improve hover (quick info) for GraphQL documents. Hovering a field now shows a proper signature line — `ParentType.fieldName(arg: ArgType = default): ReturnType` — in the editor's display string, with the schema description and any `@deprecated: ` notice (falling back to the spec default "No longer supported") rendered as documentation, plus a `deprecated` JSDoc tag so editors can render strikethrough. Hover now also works on field and directive arguments (showing the argument's type, default value, and description), enum values, and named types such as fragment type conditions (`on Pokemon`). diff --git a/packages/graphqlsp/src/quickInfo.ts b/packages/graphqlsp/src/quickInfo.ts index 20fdc70..a1f7aba 100644 --- a/packages/graphqlsp/src/quickInfo.ts +++ b/packages/graphqlsp/src/quickInfo.ts @@ -1,6 +1,7 @@ import { ts } from './ts'; -import { getHoverInformation } from 'graphql-language-service'; -import { GraphQLSchema } from 'graphql'; +import { getTokenAtPosition, getTypeInfo } from 'graphql-language-service'; +import type { GraphQLArgument, GraphQLSchema } from 'graphql'; +import { astFromValue, getNamedType, print } from 'graphql'; import { bubbleUpCallExpression, @@ -16,6 +17,119 @@ import { getToken } from './ast/token'; import { Cursor } from './ast/cursor'; import { SchemaRef, getSchemaForName } from './graphql/getSchema'; +/** Matches the GraphQL spec's default reason for `@deprecated`. */ +const DEFAULT_DEPRECATION_REASON = 'No longer supported'; + +const printDefaultValue = (arg: GraphQLArgument): string => { + if (arg.defaultValue === undefined) return ''; + const valueAst = astFromValue(arg.defaultValue, arg.type); + return valueAst ? ` = ${print(valueAst)}` : ''; +}; + +const printArg = (arg: GraphQLArgument): string => + `${arg.name}: ${arg.type}${printDefaultValue(arg)}`; + +type TypeInfo = ReturnType; + +const printQualifiedField = (typeInfo: TypeInfo): string => { + const fieldDef = typeInfo.fieldDef!; + return fieldDef.name.startsWith('__') || !typeInfo.parentType + ? fieldDef.name + : `${typeInfo.parentType}.${fieldDef.name}`; +}; + +interface HoverInfo { + signature: string; + description: string | null | undefined; + deprecationReason: string | null | undefined; +} + +export const getHoverInfo = ( + schema: GraphQLSchema, + text: string, + cursor: Cursor +): HoverInfo | undefined => { + const token = getTokenAtPosition(text, cursor); + if (!token.state) return undefined; + + const { kind, step } = token.state; + const typeInfo = getTypeInfo(schema, token.state); + + if ( + ((kind === 'Field' && step === 0) || + (kind === 'AliasedField' && step === 2)) && + typeInfo.fieldDef + ) { + const fieldDef = typeInfo.fieldDef; + const args = fieldDef.args.length + ? `(${fieldDef.args.map(printArg).join(', ')})` + : ''; + return { + signature: `${printQualifiedField(typeInfo)}${args}: ${fieldDef.type}`, + description: fieldDef.description, + deprecationReason: fieldDef.deprecationReason, + }; + } else if (kind === 'ObjectField' && step === 0 && typeInfo.fieldDef) { + const fieldDef = typeInfo.fieldDef; + return { + signature: `${printQualifiedField(typeInfo)}: ${fieldDef.type}`, + description: fieldDef.description, + deprecationReason: fieldDef.deprecationReason, + }; + } else if (kind === 'Variable' && typeInfo.type) { + const namedType = getNamedType(typeInfo.type); + return { + signature: `${typeInfo.type}`, + description: namedType.description, + deprecationReason: undefined, + }; + } else if (kind === 'Argument' && step === 0 && typeInfo.argDef) { + const argDef = typeInfo.argDef; + const prefix = typeInfo.directiveDef + ? `@${typeInfo.directiveDef.name}` + : typeInfo.fieldDef + ? printQualifiedField(typeInfo) + : ''; + return { + signature: `${prefix}(${printArg(argDef)})`, + description: argDef.description, + deprecationReason: argDef.deprecationReason, + }; + } else if (kind === 'Directive' && step === 1 && typeInfo.directiveDef) { + return { + signature: `@${typeInfo.directiveDef.name}`, + description: typeInfo.directiveDef.description, + deprecationReason: undefined, + }; + } else if ( + kind === 'EnumValue' && + typeInfo.enumValue && + 'description' in typeInfo.enumValue + ) { + const enumValue = typeInfo.enumValue; + const enumType = typeInfo.inputType + ? getNamedType(typeInfo.inputType) + : undefined; + return { + signature: enumType ? `${enumType}.${enumValue.name}` : enumValue.name, + description: enumValue.description, + deprecationReason: enumValue.deprecationReason, + }; + } else if ( + kind === 'NamedType' && + typeInfo.type && + 'description' in typeInfo.type + ) { + return { + signature: `${typeInfo.type}`, + description: typeInfo.type.description, + deprecationReason: undefined, + }; + } else { + return undefined; + } +}; + export function getGraphQLQuickInfo( filename: string, cursorPosition: number, @@ -73,7 +187,22 @@ export function getGraphQLQuickInfo( return undefined; } - const hoverInfo = getHoverInformation(schemaToUse, text, cursor); + const hoverInfo = getHoverInfo(schemaToUse, text, cursor); + if (!hoverInfo) return undefined; + + const documentation: ts.SymbolDisplayPart[] = []; + const tags: ts.JSDocTagInfo[] = []; + if (hoverInfo.deprecationReason != null) { + const reason = hoverInfo.deprecationReason || DEFAULT_DEPRECATION_REASON; + documentation.push({ kind: 'text', text: `@deprecated: ${reason}` }); + tags.push({ name: 'deprecated', text: [{ kind: 'text', text: reason }] }); + } + + if (hoverInfo.description) { + if (documentation.length) + documentation.push({ kind: 'text', text: '\n\n' }); + documentation.push({ kind: 'text', text: hoverInfo.description }); + } return { kind: ts.ScriptElementKind.label, @@ -82,8 +211,8 @@ export function getGraphQLQuickInfo( length: 1, }, kindModifiers: 'text', - documentation: Array.isArray(hoverInfo) - ? hoverInfo.map(item => ({ kind: 'text', text: item as string })) - : [{ kind: 'text', text: hoverInfo as string }], - } as ts.QuickInfo; + displayParts: [{ kind: 'text', text: hoverInfo.signature }], + documentation, + tags, + }; } diff --git a/test/e2e/client-preset.test.ts b/test/e2e/client-preset.test.ts index 0057d3b..5181ac5 100644 --- a/test/e2e/client-preset.test.ts +++ b/test/e2e/client-preset.test.ts @@ -162,7 +162,8 @@ describe('Fragment + operations', () => { expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); - expect(res?.body.documentation).toEqual(`Pokemon.name: String!`); + expect(res?.body.displayString).toEqual(`Pokemon.name: String!`); + expect(res?.body.documentation).toEqual(''); }, 30000); it('gives quick-info with documents', async () => { @@ -188,10 +189,11 @@ describe('Fragment + operations', () => { expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); + expect(res?.body.displayString).toEqual( + `Query.pokemons(limit: Int, skip: Int): [Pokemon]` + ); expect(res?.body.documentation).toEqual( - `Query.pokemons: [Pokemon] - -List out all Pokémon, optionally in pages` + `List out all Pokémon, optionally in pages` ); }, 30000); diff --git a/test/e2e/combinations.test.ts b/test/e2e/combinations.test.ts index 4d063f4..249c3a6 100644 --- a/test/e2e/combinations.test.ts +++ b/test/e2e/combinations.test.ts @@ -116,9 +116,8 @@ describe('Fragment + operations', () => { expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); - expect(res?.body.documentation).toEqual( - `Query.posts: [Post]\n\nList out all posts` - ); + expect(res?.body.displayString).toEqual(`Query.posts: [Post]`); + expect(res?.body.documentation).toEqual(`List out all posts`); }, 30000); it('gives suggestions with preceding fragments', async () => { diff --git a/test/e2e/fixture-project-tada/schema.graphql b/test/e2e/fixture-project-tada/schema.graphql index 148f0b7..4c25a88 100644 --- a/test/e2e/fixture-project-tada/schema.graphql +++ b/test/e2e/fixture-project-tada/schema.graphql @@ -90,5 +90,5 @@ type Query { """ List out all Pokémon, optionally in pages """ - pokemons(limit: Int, skip: Int): [Pokemon] + pokemons(limit: Int = 10, skip: Int): [Pokemon] } \ No newline at end of file diff --git a/test/e2e/graphqlsp.test.ts b/test/e2e/graphqlsp.test.ts index 73f1e00..9e9eacb 100644 --- a/test/e2e/graphqlsp.test.ts +++ b/test/e2e/graphqlsp.test.ts @@ -171,9 +171,8 @@ describe('simple', () => { .find(resp => resp.type === 'response' && resp.command === 'quickinfo'); expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); - expect(res?.body.documentation).toEqual( - `Query.posts: [Post]\n\nList out all posts` - ); + expect(res?.body.displayString).toEqual(`Query.posts: [Post]`); + expect(res?.body.documentation).toEqual(`List out all posts`); }, 7500); it('Handles empty line (#190)', async () => { diff --git a/test/e2e/multi-schema-tada.test.ts b/test/e2e/multi-schema-tada.test.ts index 885d376..80f5b4b 100644 --- a/test/e2e/multi-schema-tada.test.ts +++ b/test/e2e/multi-schema-tada.test.ts @@ -132,7 +132,8 @@ describe('Multiple schemas', () => { expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); - expect(res?.body.documentation).toEqual(`Pokemon.name: String!`); + expect(res?.body.displayString).toEqual(`Pokemon.name: String!`); + expect(res?.body.documentation).toEqual(''); }, 30000); it('gives quick-info for the pokemon document namespace', async () => { @@ -158,7 +159,8 @@ describe('Multiple schemas', () => { expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); - expect(res?.body.documentation).toEqual(`Pokemon.name: String!`); + expect(res?.body.displayString).toEqual(`Pokemon.name: String!`); + expect(res?.body.documentation).toEqual(''); }, 30000); it('gives quick-info for the todo document', async () => { @@ -184,7 +186,8 @@ describe('Multiple schemas', () => { expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); - expect(res?.body.documentation).toEqual(`Todo.id: ID!`); + expect(res?.body.displayString).toEqual(`Todo.id: ID!`); + expect(res?.body.documentation).toEqual(''); }, 30000); it('gives completion-info for the pokemon document', async () => { diff --git a/test/e2e/tada.test.ts b/test/e2e/tada.test.ts index e235d95..229cbd1 100644 --- a/test/e2e/tada.test.ts +++ b/test/e2e/tada.test.ts @@ -186,7 +186,8 @@ describe('Fragment + operations', () => { expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); - expect(res?.body.documentation).toEqual(`Pokemon.name: String!`); + expect(res?.body.displayString).toEqual(`Pokemon.name: String!`); + expect(res?.body.documentation).toEqual(''); }, 30000); it('gives quick-info with documents', async () => { @@ -212,11 +213,98 @@ describe('Fragment + operations', () => { expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); + expect(res?.body.displayString).toEqual( + `Query.pokemons(limit: Int = 10, skip: Int): [Pokemon]` + ); expect(res?.body.documentation).toEqual( - `Query.pokemons: [Pokemon] + `List out all Pokémon, optionally in pages` + ); + }, 30000); + + it('gives quick-info for deprecated fields', async () => { + server.send({ + seq: 14, + type: 'request', + command: 'quickinfo', + arguments: { + file: outfileCombo, + line: 11, + offset: 9, + }, + }); + + await server.waitForResponse( + response => + response.type === 'response' && response.command === 'quickinfo' + ); + + const res = server.responses + .reverse() + .find(resp => resp.type === 'response' && resp.command === 'quickinfo'); + + expect(res).toBeDefined(); + expect(typeof res?.body).toEqual('object'); + expect(res?.body.displayString).toEqual(`Pokemon.classification: String`); + expect(res?.body.documentation).toEqual( + `@deprecated: And this is the reason why` + ); + expect(res?.body.tags).toEqual([ + { name: 'deprecated', text: 'And this is the reason why' }, + ]); + }, 30000); + + it('gives quick-info for field arguments', async () => { + server.send({ + seq: 15, + type: 'request', + command: 'quickinfo', + arguments: { + file: outfileCombo, + line: 7, + offset: 15, + }, + }); -List out all Pokémon, optionally in pages` + await server.waitForResponse( + response => + response.type === 'response' && response.command === 'quickinfo' ); + + const res = server.responses + .reverse() + .find(resp => resp.type === 'response' && resp.command === 'quickinfo'); + + expect(res).toBeDefined(); + expect(typeof res?.body).toEqual('object'); + expect(res?.body.displayString).toEqual(`Query.pokemons(limit: Int = 10)`); + expect(res?.body.documentation).toEqual(''); + }, 30000); + + it('gives quick-info for fragment type-conditions', async () => { + server.send({ + seq: 16, + type: 'request', + command: 'quickinfo', + arguments: { + file: outfileCombinations, + line: 5, + offset: 31, + }, + }); + + await server.waitForResponse( + response => + response.type === 'response' && response.command === 'quickinfo' + ); + + const res = server.responses + .reverse() + .find(resp => resp.type === 'response' && resp.command === 'quickinfo'); + + expect(res).toBeDefined(); + expect(typeof res?.body).toEqual('object'); + expect(res?.body.displayString).toEqual(`Pokemon`); + expect(res?.body.documentation).toEqual(''); }, 30000); it('gives suggestions with preceding fragments', async () => { @@ -538,7 +626,8 @@ List out all Pokémon, optionally in pages` expect(res).toBeDefined(); expect(typeof res?.body).toEqual('object'); - expect(res?.body.documentation).toEqual(`Pokemon.name: String!`); + expect(res?.body.displayString).toEqual(`Pokemon.name: String!`); + expect(res?.body.documentation).toEqual(''); }, 30000); it('gives suggestions with empty line (#190)', async () => { diff --git a/test/unit/quickInfo.test.ts b/test/unit/quickInfo.test.ts new file mode 100644 index 0000000..83b308f --- /dev/null +++ b/test/unit/quickInfo.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { buildSchema } from '../../packages/graphqlsp/node_modules/graphql/index.js'; + +import { Cursor } from '../../packages/graphqlsp/src/ast/cursor'; +import { getHoverInfo } from '../../packages/graphqlsp/src/quickInfo'; + +const schema = buildSchema(` + input SearchFilter { + "Text to match" + term: String + } + + type Query { + search(filter: SearchFilter): String + } +`); + +const query = + 'query Search($term: String!) { search(filter: { term: $term }) }'; + +const cursorAt = (needle: string) => new Cursor(0, query.indexOf(needle) + 1); + +describe('getHoverInfo', () => { + it('preserves variable hover information', () => { + const hover = getHoverInfo(schema, query, cursorAt('$term })')); + + expect(hover).toEqual({ + signature: 'String', + description: + 'The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.', + deprecationReason: undefined, + }); + }); + + it('preserves input object field hover information', () => { + const hover = getHoverInfo(schema, query, cursorAt('term: $term')); + + expect(hover).toEqual({ + signature: 'Query.term: String', + description: 'Text to match', + deprecationReason: undefined, + }); + }); +});