Skip to content

Commit

Permalink
Merge pull request #1248 from Quramy/goto_def
Browse files Browse the repository at this point in the history
feat: Add getDefinitionAtPosition
  • Loading branch information
Quramy committed Mar 27, 2024
2 parents b7e62b1 + 6ed82f8 commit da16ec7
Show file tree
Hide file tree
Showing 10 changed files with 372 additions and 1 deletion.
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;
}

0 comments on commit da16ec7

Please sign in to comment.