diff --git a/README.md b/README.md index 07843775..169ba0c2 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This plugin has the following features: - As TypeScript Language Service extension: - Completion suggestion - Get GraphQL diagnostics + - Go to fragment definition - Display GraphQL quick info within tooltip - As CLI - Generate ts type files from your GraphQL operations in your TypeScript sources @@ -382,6 +383,8 @@ const appQuery = graphql(` In the above example, note that `${postFragment}` is not in the `appQuery` template string literal. +If this option is set `true`, you can go to definition of the fragment from the location where the fragment is used as spread reference. + > [!IMPORTANT] > This option does not depend on whether the query and fragment are combined at runtime. > Whether or not query and fragment are eventually combined depends on the build toolchain you are using. diff --git a/e2e/lang-server-specs/definition.js b/e2e/lang-server-specs/definition.js new file mode 100644 index 00000000..5f3c2d61 --- /dev/null +++ b/e2e/lang-server-specs/definition.js @@ -0,0 +1,67 @@ +const assert = require('assert'); +const path = require('path'); +const { mark } = require('fretted-strings'); + +function findResponse(responses, commandName) { + return responses.find(response => response.command === commandName); +} + +async function run(server) { + const fileFragments = path.resolve(__dirname, '../../project-fixtures/simple-prj/fragments.ts'); + const fileMain = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts'); + const frets = {}; + const fileFragmentsContent = ` + const fragment = gql\` + fragment MyFragment on Query { + hello + } + \`; + `; + const fileMainContent = mark( + ` + const query = gql\` + query MyQuery { + ...MyFragment + %%% ^ %%% + %%% p %%% + } + \`; + `, + frets, + ); + server.send({ + command: 'open', + arguments: { file: fileFragments, fileContent: fileFragmentsContent, scriptKindName: 'TS' }, + }); + server.send({ command: 'open', arguments: { file: fileMain, fileContent: fileMainContent, scriptKindName: 'TS' } }); + + await server.waitEvent('projectLoadingFinish'); + + server.send({ + command: 'definition', + arguments: { file: fileMain, offset: frets.p.character + 1, line: frets.p.line + 1, prefix: '' }, + }); + + await server.waitResponse('definition'); + + server.send({ + command: 'definitionAndBoundSpan', + arguments: { file: fileMain, offset: frets.p.character + 1, line: frets.p.line + 1, prefix: '' }, + }); + + await server.waitResponse('definitionAndBoundSpan'); + + await server.close(); + + const definitionResponse = findResponse(server.responses, 'definition'); + assert(!!definitionResponse); + assert(definitionResponse.body.length === 1); + assert(definitionResponse.body[0].file === fileFragments); + + const definitionAndBoundSpanResponse = findResponse(server.responses, 'definitionAndBoundSpan'); + assert(!!definitionAndBoundSpanResponse); + assert(definitionAndBoundSpanResponse.body.definitions.length === 1); + assert(definitionAndBoundSpanResponse.body.definitions[0].file === fileFragments); +} + +module.exports = run; diff --git a/src/gql-ast-util/fragment-registry.ts b/src/gql-ast-util/fragment-registry.ts index f8398504..78b46908 100644 --- a/src/gql-ast-util/fragment-registry.ts +++ b/src/gql-ast-util/fragment-registry.ts @@ -313,6 +313,10 @@ export class FragmentRegistry { }; } + getFragmentDefinitionEntryDetail(name: string) { + return this._store.getUniqueDefinitonMap().get(name); + } + getDuplicaterdFragmentDefinitions() { return new Set(this._store.getDuplicatedDefinitonMap().keys()); } diff --git a/src/graphql-language-service-adapter/get-definition-and-bound-span.test.ts b/src/graphql-language-service-adapter/get-definition-and-bound-span.test.ts new file mode 100644 index 00000000..29c39bb0 --- /dev/null +++ b/src/graphql-language-service-adapter/get-definition-and-bound-span.test.ts @@ -0,0 +1,140 @@ +import ts from 'typescript'; +import { mark, type Frets } from 'fretted-strings'; +import { AdapterFixture } from './testing/adapter-fixture'; + +function createFixture(name: string) { + return new AdapterFixture(name); +} + +describe('getDefinitionAndBoundSpan', () => { + const delegateFn = jest.fn(() => undefined); + + beforeEach(() => { + delegateFn.mockClear(); + }); + + it.each([ + { + name: 'cursor on not template leteral', + source: ` + const query = 100; + %%% ^ %%% + %%% s1 %%% + `, + }, + { + name: 'incomplete operation', + source: ` + const query = \` + query MyQuery { + ...MyFragment + %%% ^ %%% + %%% s1 %%% + \`; + `, + }, + { + name: 'cursor on not fragment spread', + source: ` + const query = \` + query MyQuery { + ...MyFragment + %%% ^ %%% + %%% s1 %%% + } + + fragment MyFragment on Query { + __typename + } + \`; + `, + }, + { + name: 'not exsisting fragment definition', + source: ` + const query = \` + query MyQuery { + ...MyFragment + %%% ^ %%% + %%% s1 %%% + } + + fragment OtherFragment on Query { + __typename + } + \`; + `, + }, + ])('should return no definition info for $name .', ({ source }) => { + const fixture = createFixture('input.ts'); + const frets: Frets = {}; + fixture.source = mark(source, frets); + fixture.adapter.getDefinitionAndBoundSpan(delegateFn, 'input.ts', frets.s1.pos); + expect(delegateFn).toHaveBeenCalledTimes(1); + }); + + it('should return definition info when cursor is on fragment spread', () => { + const fixture = createFixture('input.ts'); + const frets: Frets = {}; + fixture.source = mark( + ` + const query = \` + query MyQuery { + ...MyFragment + %%% ^ ^ %%% + %%% s1 s2 %%% + } + + fragment MyFragment on Query { + %%% ^ ^ %%% + %%% d1 d2 %%% + __typename + } + \`; + `, + frets, + ); + const actual = fixture.adapter.getDefinitionAndBoundSpan(delegateFn, 'input.ts', frets.s1.pos); + expect(actual).toMatchObject({ + textSpan: { + start: frets.s1.pos, + length: frets.s2.pos - frets.s1.pos, + }, + definitions: [ + { + fileName: 'input.ts', + textSpan: { + start: frets.d1.pos, + length: frets.d2.pos - frets.d1.pos, + }, + }, + ] as Partial[], + }); + }); + + it('should return definition to other file', () => { + const fixture = createFixture('input.ts'); + const frets: Frets = {}; + fixture.registerFragment( + 'fragments.ts', + ` + fragment MyFragment on Query { + __typename + } + `, + ).source = mark( + ` + const query = \` + query MyQuery { + ...MyFragment + %%% ^ %%% + %%% s1 %%% + } + \`; + `, + frets, + ); + const actual = fixture.adapter.getDefinitionAndBoundSpan(delegateFn, 'input.ts', frets.s1.pos); + expect(actual?.definitions?.[0].fileName).toBe('fragments.ts'); + }); +}); diff --git a/src/graphql-language-service-adapter/get-definition-and-bound-span.ts b/src/graphql-language-service-adapter/get-definition-and-bound-span.ts new file mode 100644 index 00000000..cbba5b91 --- /dev/null +++ b/src/graphql-language-service-adapter/get-definition-and-bound-span.ts @@ -0,0 +1,50 @@ +import ts from 'typescript'; +import { visit, type FragmentSpreadNode } from 'graphql'; +import { getSanitizedTemplateText } from '../ts-ast-util'; +import type { AnalysisContext, GetDefinitionAndBoundSpan } from './types'; + +export function getDefinitionAndBoundSpan( + ctx: AnalysisContext, + delegate: GetDefinitionAndBoundSpan, + fileName: string, + position: number, +) { + if (ctx.getScriptSourceHelper().isExcluded(fileName)) return delegate(fileName, position); + const node = ctx.findAscendantTemplateNode(fileName, position); + if (!node) return delegate(fileName, position); + const { text, sourcePosition } = getSanitizedTemplateText(node); + const documentNode = ctx.getGraphQLDocumentNode(text); + if (!documentNode) return delegate(fileName, position); + const innerPosition = position - sourcePosition; + let fragmentSpreadNodeUnderCursor: FragmentSpreadNode | undefined; + visit(documentNode, { + FragmentSpread: node => { + if (node.name.loc!.start <= innerPosition && innerPosition < node.name.loc!.end) { + fragmentSpreadNodeUnderCursor = node; + } + }, + }); + if (!fragmentSpreadNodeUnderCursor) return delegate(fileName, position); + const foundDefinitionDetail = ctx.getGlobalFragmentDefinitionEntry(fragmentSpreadNodeUnderCursor.name.value); + if (!foundDefinitionDetail) return delegate(fileName, position); + const definitionSourcePosition = foundDefinitionDetail.position + foundDefinitionDetail.node.name.loc!.start; + return { + textSpan: { + start: sourcePosition + fragmentSpreadNodeUnderCursor.name.loc!.start, + length: fragmentSpreadNodeUnderCursor.name.loc!.end - fragmentSpreadNodeUnderCursor.name.loc!.start, + }, + definitions: [ + { + fileName: foundDefinitionDetail.fileName, + name: foundDefinitionDetail.node.name.value, + textSpan: { + start: definitionSourcePosition, + length: foundDefinitionDetail.node.name.loc!.end - foundDefinitionDetail.node.name.loc!.start, + }, + kind: ts.ScriptElementKind.unknown, + containerKind: ts.ScriptElementKind.unknown, + containerName: '', + }, + ], + } satisfies ts.DefinitionInfoAndBoundSpan; +} diff --git a/src/graphql-language-service-adapter/get-definition-at-position.test.ts b/src/graphql-language-service-adapter/get-definition-at-position.test.ts new file mode 100644 index 00000000..3984339e --- /dev/null +++ b/src/graphql-language-service-adapter/get-definition-at-position.test.ts @@ -0,0 +1,56 @@ +import { mark, type Frets } from 'fretted-strings'; +import { AdapterFixture } from './testing/adapter-fixture'; + +function createFixture(name: string) { + return new AdapterFixture(name); +} + +describe('getDefinitionAtPosition', () => { + const delegateFn = jest.fn(() => []); + + it('should not return definition info when the cursor does not point fragment spread', () => { + const fixture = createFixture('input.ts'); + const frets: Frets = {}; + fixture.source = mark( + ` + const query = \` + query MyQuery { + %%% ^ %%% + %%% cur %%% + ...MyFragment + } + + fragment MyFragment on Query { + __typename + } + \`; + `, + frets, + ); + const actual = fixture.adapter.getDefinitionAtPosition(delegateFn, 'input.ts', frets.cur.pos); + expect(actual?.length).toBe(0); + }); + + it('should return definition of fragment spread under cursor', () => { + const fixture = createFixture('input.ts'); + const frets: Frets = {}; + fixture.source = mark( + ` + const query = \` + query MyQuery { + ...MyFragment + %%% ^ %%% + %%% cur %%% + } + + fragment MyFragment on Query { + __typename + } + \`; + `, + frets, + ); + const actual = fixture.adapter.getDefinitionAtPosition(delegateFn, 'input.ts', frets.cur.pos); + expect(actual?.length).toBe(1); + }); +}); diff --git a/src/graphql-language-service-adapter/get-definition-at-position.ts b/src/graphql-language-service-adapter/get-definition-at-position.ts new file mode 100644 index 00000000..ba70c908 --- /dev/null +++ b/src/graphql-language-service-adapter/get-definition-at-position.ts @@ -0,0 +1,13 @@ +import type { AnalysisContext, GetDefinitionAtPosition } from './types'; +import { getDefinitionAndBoundSpan } from './get-definition-and-bound-span'; + +export function getDefinitionAtPosition( + ctx: AnalysisContext, + delegate: GetDefinitionAtPosition, + fileName: string, + position: number, +) { + const result = getDefinitionAndBoundSpan(ctx, () => undefined, fileName, position); + if (!result) return delegate(fileName, position); + return result.definitions; +} diff --git a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts index c2218aed..9ccfaaeb 100644 --- a/src/graphql-language-service-adapter/graphql-language-service-adapter.ts +++ b/src/graphql-language-service-adapter/graphql-language-service-adapter.ts @@ -9,10 +9,19 @@ import { } from '../ts-ast-util'; import type { SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; import { getFragmentNamesInDocument, detectDuplicatedFragments, type FragmentRegistry } from '../gql-ast-util'; -import type { AnalysisContext, GetCompletionAtPosition, GetSemanticDiagnostics, GetQuickInfoAtPosition } from './types'; +import type { + AnalysisContext, + GetCompletionAtPosition, + GetSemanticDiagnostics, + GetQuickInfoAtPosition, + GetDefinitionAndBoundSpan, + GetDefinitionAtPosition, +} from './types'; import { getCompletionAtPosition } from './get-completion-at-position'; import { getSemanticDiagnostics } from './get-semantic-diagnostics'; import { getQuickInfoAtPosition } from './get-quick-info-at-position'; +import { getDefinitionAtPosition } from './get-definition-at-position'; +import { getDefinitionAndBoundSpan } from './get-definition-and-bound-span'; import { LRUCache } from '../cache'; export interface GraphQLLanguageServiceAdapterCreateOptions { @@ -58,6 +67,14 @@ export class GraphQLLanguageServiceAdapter { return getQuickInfoAtPosition(this._analysisContext, delegate, ...args); } + getDefinitionAndBoundSpan(delegate: GetDefinitionAndBoundSpan, ...args: Parameters) { + return getDefinitionAndBoundSpan(this._analysisContext, delegate, ...args); + } + + getDefinitionAtPosition(delegate: GetDefinitionAtPosition, ...args: Parameters) { + return getDefinitionAtPosition(this._analysisContext, delegate, ...args); + } + updateSchema(errors: SchemaBuildErrorInfo[] | null, schema: GraphQLSchema | null) { if (errors) { this._schemaErrors = errors; @@ -82,6 +99,20 @@ export class GraphQLLanguageServiceAdapter { }, getGraphQLDocumentNode: text => this._parse(text), getGlobalFragmentDefinitions: () => this._fragmentRegisry.getFragmentDefinitions(), + getGlobalFragmentDefinitionEntry: (name: string) => { + const detail = this._fragmentRegisry.getFragmentDefinitionEntryDetail(name); + if (!detail) return undefined; + const { + node, + fileName, + extra: { sourcePosition }, + } = detail; + return { + fileName, + node, + position: sourcePosition, + }; + }, getExternalFragmentDefinitions: (documentStr, fileName, sourcePosition) => this._fragmentRegisry.getExternalFragments(documentStr, fileName, sourcePosition), getDuplicaterdFragmentDefinitions: () => this._fragmentRegisry.getDuplicaterdFragmentDefinitions(), diff --git a/src/graphql-language-service-adapter/types.ts b/src/graphql-language-service-adapter/types.ts index 9c3225d4..2a36f9fa 100644 --- a/src/graphql-language-service-adapter/types.ts +++ b/src/graphql-language-service-adapter/types.ts @@ -6,6 +6,8 @@ import type { SchemaBuildErrorInfo } from '../schema-manager/schema-manager'; export type GetCompletionAtPosition = ts.LanguageService['getCompletionsAtPosition']; export type GetSemanticDiagnostics = ts.LanguageService['getSemanticDiagnostics']; export type GetQuickInfoAtPosition = ts.LanguageService['getQuickInfoAtPosition']; +export type GetDefinitionAndBoundSpan = ts.LanguageService['getDefinitionAndBoundSpan']; +export type GetDefinitionAtPosition = ts.LanguageService['getDefinitionAtPosition']; export interface AnalysisContext { debug(msg: string): void; @@ -13,6 +15,9 @@ export interface AnalysisContext { getSchema(): GraphQLSchema | null | undefined; getSchemaOrSchemaErrors(): [GraphQLSchema, null] | [null, SchemaBuildErrorInfo[]]; getGlobalFragmentDefinitions(): FragmentDefinitionNode[]; + getGlobalFragmentDefinitionEntry( + name: string, + ): { node: FragmentDefinitionNode; fileName: string; position: number } | undefined; getExternalFragmentDefinitions( documentStr: string, fileName: string, diff --git a/src/language-service-plugin/plugin-module-factory.ts b/src/language-service-plugin/plugin-module-factory.ts index 2e2b4cce..f1f9427f 100644 --- a/src/language-service-plugin/plugin-module-factory.ts +++ b/src/language-service-plugin/plugin-module-factory.ts @@ -74,6 +74,8 @@ function create(info: ts.server.PluginCreateInfo): ts.LanguageService { .wrap('getCompletionsAtPosition', delegate => adapter.getCompletionAtPosition.bind(adapter, delegate)) .wrap('getSemanticDiagnostics', delegate => adapter.getSemanticDiagnostics.bind(adapter, delegate)) .wrap('getQuickInfoAtPosition', delegate => adapter.getQuickInfoAtPosition.bind(adapter, delegate)) + .wrap('getDefinitionAndBoundSpan', delegate => adapter.getDefinitionAndBoundSpan.bind(adapter, delegate)) + .wrap('getDefinitionAtPosition', delegate => adapter.getDefinitionAtPosition.bind(adapter, delegate)) .build(); schemaManager.registerOnChange(adapter.updateSchema.bind(adapter));