Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add getDefinitionAtPosition #1248

Merged
merged 4 commits into from Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions 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;
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 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<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;
}