Skip to content

Commit

Permalink
feat: Add definitionAndBoundSpan method to lang service
Browse files Browse the repository at this point in the history
  • Loading branch information
Quramy committed Mar 19, 2024
1 parent b2721bd commit 228d10a
Show file tree
Hide file tree
Showing 8 changed files with 302 additions and 1 deletion.
4 changes: 4 additions & 0 deletions src/gql-ast-util/fragment-registry.ts
Expand Up @@ -313,6 +313,10 @@ export class FragmentRegistry {
};
}

getFragmentDefinitionEntryDetail(name: string) {
return this._store.getUniqueDefinitonMap().get(name);
}

getDuplicaterdFragmentDefinitions() {
return new Set(this._store.getDuplicatedDefinitonMap().keys());
}
Expand Down
@@ -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<ts.DefinitionInfo>[],
});
});

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');
});
});
@@ -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;
}
@@ -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);
});
});
13 changes: 13 additions & 0 deletions 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;
}
Expand Up @@ -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 {
Expand Down Expand Up @@ -58,6 +67,14 @@ export class GraphQLLanguageServiceAdapter {
return getQuickInfoAtPosition(this._analysisContext, delegate, ...args);
}

getDefinitionAndBoundSpan(delegate: GetDefinitionAndBoundSpan, ...args: Parameters<GetDefinitionAndBoundSpan>) {
return getDefinitionAndBoundSpan(this._analysisContext, delegate, ...args);
}

getDefinitionAtPosition(delegate: GetDefinitionAtPosition, ...args: Parameters<GetDefinitionAtPosition>) {
return getDefinitionAtPosition(this._analysisContext, delegate, ...args);
}

updateSchema(errors: SchemaBuildErrorInfo[] | null, schema: GraphQLSchema | null) {
if (errors) {
this._schemaErrors = errors;
Expand All @@ -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(),
Expand Down
5 changes: 5 additions & 0 deletions src/graphql-language-service-adapter/types.ts
Expand Up @@ -6,13 +6,18 @@ 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;
getScriptSourceHelper(): ScriptSourceHelper;
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,
Expand Down
2 changes: 2 additions & 0 deletions src/language-service-plugin/plugin-module-factory.ts
Expand Up @@ -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));
Expand Down

0 comments on commit 228d10a

Please sign in to comment.