From 228d10a3f9e5921ade1d12b69a55f36d96f46264 Mon Sep 17 00:00:00 2001 From: Quramy Date: Tue, 19 Mar 2024 19:30:03 +0900 Subject: [PATCH 1/4] feat: Add definitionAndBoundSpan method to lang service --- src/gql-ast-util/fragment-registry.ts | 4 + .../get-definition-and-bound-span.test.ts | 140 ++++++++++++++++++ .../get-definition-and-bound-span.ts | 50 +++++++ .../get-definition-at-position.test.ts | 56 +++++++ .../get-definition-at-position.ts | 13 ++ .../graphql-language-service-adapter.ts | 33 ++++- src/graphql-language-service-adapter/types.ts | 5 + .../plugin-module-factory.ts | 2 + 8 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 src/graphql-language-service-adapter/get-definition-and-bound-span.test.ts create mode 100644 src/graphql-language-service-adapter/get-definition-and-bound-span.ts create mode 100644 src/graphql-language-service-adapter/get-definition-at-position.test.ts create mode 100644 src/graphql-language-service-adapter/get-definition-at-position.ts 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..2a2d39b2 --- /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 not return nothing 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)); From ce85402b1e9ad6bcd425faf0254b965cbf484a8b Mon Sep 17 00:00:00 2001 From: Quramy Date: Tue, 19 Mar 2024 19:30:51 +0900 Subject: [PATCH 2/4] test: Add e2e spec for get definition --- e2e/lang-server-specs/definition.js | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 e2e/lang-server-specs/definition.js 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; From 7276e58fe658b393121c193f3913d3f708510617 Mon Sep 17 00:00:00 2001 From: Quramy Date: Fri, 22 Mar 2024 16:16:01 +0900 Subject: [PATCH 3/4] test: Fix test case name --- .../get-definition-and-bound-span.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2a2d39b2..29c39bb0 100644 --- 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 @@ -65,7 +65,7 @@ describe('getDefinitionAndBoundSpan', () => { \`; `, }, - ])('should not return nothing for $name .', ({ source }) => { + ])('should return no definition info for $name .', ({ source }) => { const fixture = createFixture('input.ts'); const frets: Frets = {}; fixture.source = mark(source, frets); From 6ed82f850094e1220720fde5937e9a5fc94304b2 Mon Sep 17 00:00:00 2001 From: Quramy Date: Wed, 27 Mar 2024 12:25:17 +0900 Subject: [PATCH 4/4] docs: Write about go to definition feature --- README.md | 3 +++ 1 file changed, 3 insertions(+) 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.