diff --git a/.changeset/fragment-rename-references.md b/.changeset/fragment-rename-references.md new file mode 100644 index 00000000..6c95a5cc --- /dev/null +++ b/.changeset/fragment-rename-references.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Add find-references and rename support for GraphQL fragments. Placing the cursor on a fragment name — either at its definition (`fragment PokemonFields on Pokemon`) or at a spread (`...PokemonFields`) — now lists the definition and every spread of that fragment across the project's source files, and renaming from any of those locations updates all of them at once, including fragments defined in other `graphql()` calls in the same file and fragments imported from other files. diff --git a/README.md b/README.md index 574a7e28..2393455f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ auto-complete. - Hover information showing the decriptions of fields - Diagnostics for adding fields that don't exist, are deprecated, missmatched argument types, ... - Auto-complete inside your editor for fields +- Find references and rename for GraphQL fragments, across your project's files - Will warn you when you are importing from a file that is exporting fragments that you're not using > Note that this plugin does not do syntax highlighting, for that you still need something like diff --git a/packages/graphqlsp/src/index.ts b/packages/graphqlsp/src/index.ts index bfe33065..52cd996e 100644 --- a/packages/graphqlsp/src/index.ts +++ b/packages/graphqlsp/src/index.ts @@ -9,6 +9,12 @@ import { getGraphQLDefinitionAtPosition, } from './definition'; import { ALL_DIAGNOSTICS, getGraphQLDiagnostics } from './diagnostics'; +import { + getGraphQLFragmentReferences, + getGraphQLFragmentReferenceEntries, + getGraphQLFragmentRenameInfo, + getGraphQLFragmentRenameLocations, +} from './references'; import { templates } from './ast/templates'; import { getPersistedCodeFixAtPosition } from './persisted'; @@ -249,6 +255,68 @@ function create(info: ts.server.PluginCreateInfo) { return definition || original; }; + proxy.findReferences = (filename: string, cursorPosition: number) => { + const references = guard('findReferences', undefined, () => + getGraphQLFragmentReferences(filename, cursorPosition, info) + ); + + return ( + references || + info.languageService.findReferences(filename, cursorPosition) + ); + }; + + proxy.getReferencesAtPosition = ( + filename: string, + cursorPosition: number + ) => { + const references = guard('getReferencesAtPosition', undefined, () => + getGraphQLFragmentReferenceEntries(filename, cursorPosition, info) + ); + + return ( + references || + info.languageService.getReferencesAtPosition(filename, cursorPosition) + ); + }; + + proxy.getRenameInfo = ( + ...args: Parameters + ) => { + const [filename, cursorPosition] = args; + const renameInfo = guard('getRenameInfo', undefined, () => + getGraphQLFragmentRenameInfo(filename, cursorPosition, info) + ); + + if (renameInfo) return renameInfo; + + return info.languageService.getRenameInfo(...args); + }; + + proxy.findRenameLocations = ( + filename: string, + cursorPosition: number, + findInStrings: boolean, + findInComments: boolean, + preferences?: ts.UserPreferences | boolean + ) => { + const locations = guard('findRenameLocations', undefined, () => + getGraphQLFragmentRenameLocations(filename, cursorPosition, info) + ); + + if (locations) return locations; + + return info.languageService.findRenameLocations( + filename, + cursorPosition, + findInStrings, + findInComments, + // Both overloads of `findRenameLocations` are forwarded through the + // same call site, which the overloaded signatures can't express + preferences as ts.UserPreferences + ); + }; + proxy.getQuickInfoAtPosition = ( ...args: Parameters ) => { diff --git a/packages/graphqlsp/src/references.ts b/packages/graphqlsp/src/references.ts new file mode 100644 index 00000000..091ad85b --- /dev/null +++ b/packages/graphqlsp/src/references.ts @@ -0,0 +1,299 @@ +import type { NameNode } from 'graphql'; +import { parse, visit } from 'graphql'; + +import { ts } from './ts'; +import { + bubbleUpCallExpression, + bubbleUpTemplate, + findAllCallExpressions, + findAllTaggedTemplateNodes, + findNode, + getSource, +} from './ast'; +import * as checks from './ast/checks'; + +interface FragmentReferenceLocation { + fileName: string; + start: number; + length: number; + isDefinition: boolean; +} + +interface FragmentNameToken { + name: string; + /** Span of the name token, in TS file offsets of the originating file */ + start: number; + length: number; + schemaName: string | null; +} + +/** Strips the quotes/backticks off a document literal without unescaping, + * so GraphQL AST offsets map 1:1 onto TS file offsets. */ +const getDocumentText = (node: ts.StringLiteralLike): string => + node.getText().slice(1, -1); + +const findFragmentNameAtOffset = ( + documentText: string, + offset: number +): { name: string; start: number; length: number } | undefined => { + let document; + try { + document = parse(documentText); + } catch (_error) { + return undefined; + } + + let found: { name: string; start: number; length: number } | undefined; + const checkName = (name: NameNode) => { + const loc = name.loc; + if (loc && offset >= loc.start && offset < loc.end) { + found = { + name: name.value, + start: loc.start, + length: loc.end - loc.start, + }; + } + }; + + visit(document, { + FragmentDefinition(node) { + checkName(node.name); + }, + FragmentSpread(node) { + checkName(node.name); + }, + }); + + return found; +}; + +/** Resolves the cursor position to a fragment name token (at a fragment + * definition or a fragment spread) inside a GraphQL document. */ +const getFragmentTokenAtPosition = ( + filename: string, + cursorPosition: number, + info: ts.server.PluginCreateInfo +): FragmentNameToken | undefined => { + const isCallExpression = info.config.templateIsCallExpression ?? true; + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); + const source = getSource(info, filename); + if (!source) return undefined; + + let node = findNode(source, cursorPosition); + if (!node) return undefined; + node = isCallExpression + ? bubbleUpCallExpression(node) + : bubbleUpTemplate(node); + + let documentNode: ts.StringLiteralLike; + let schemaName: string | null = null; + + if (isCallExpression && checks.isGraphQLCall(node, typeChecker)) { + const text = node.arguments[0]; + if (!ts.isStringLiteralLike(text)) return undefined; + documentNode = text; + schemaName = checks.getSchemaName(node, typeChecker); + } else if (!isCallExpression && checks.isGraphQLTag(node)) { + // Templates with interpolations shift GraphQL offsets away from TS + // offsets, so only plain templates are supported here + if (!ts.isNoSubstitutionTemplateLiteral(node.template)) return undefined; + documentNode = node.template; + } else { + return undefined; + } + + const documentStart = documentNode.getStart() + 1; + const documentOffset = cursorPosition - documentStart; + const documentText = getDocumentText(documentNode); + if (documentOffset < 0 || documentOffset >= documentText.length) { + return undefined; + } + + const token = findFragmentNameAtOffset(documentText, documentOffset); + if (!token) return undefined; + + return { + name: token.name, + start: documentStart + token.start, + length: token.length, + schemaName, + }; +}; + +const collectFragmentReferencesInSource = ( + source: ts.SourceFile, + token: FragmentNameToken, + isCallExpression: boolean, + info: ts.server.PluginCreateInfo, + references: FragmentReferenceLocation[] +): void => { + if (source.isDeclarationFile) return; + if (source.fileName.includes('node_modules')) return; + // Parsing every document in every file is wasteful; a plain-text scan + // rules out files that can't possibly mention the fragment + if (!source.getText().includes(token.name)) return; + + const documents: Array = []; + if (isCallExpression) { + const { nodes } = findAllCallExpressions(source, info, { + searchExternal: false, + collectFragments: false, + }); + for (const found of nodes) { + // Same-named fragments may exist per schema in multi-schema setups; + // only documents for the originating schema are related + if (found.schema !== token.schemaName) continue; + documents.push(found.node); + } + } else { + for (const found of findAllTaggedTemplateNodes(source)) { + const template = ts.isTaggedTemplateExpression(found) + ? found.template + : found; + if (!ts.isNoSubstitutionTemplateLiteral(template)) continue; + documents.push(template); + } + } + + for (const documentNode of documents) { + const documentText = getDocumentText(documentNode); + if (!documentText.includes(token.name)) continue; + + let document; + try { + document = parse(documentText); + } catch (_error) { + continue; + } + + const documentStart = documentNode.getStart() + 1; + const pushName = (name: NameNode, isDefinition: boolean) => { + const loc = name.loc; + if (name.value !== token.name || !loc) return; + references.push({ + fileName: source.fileName, + start: documentStart + loc.start, + length: loc.end - loc.start, + isDefinition, + }); + }; + + visit(document, { + FragmentDefinition(node) { + pushName(node.name, true); + }, + FragmentSpread(node) { + pushName(node.name, false); + }, + }); + } +}; + +const findAllFragmentReferences = ( + token: FragmentNameToken, + info: ts.server.PluginCreateInfo +): FragmentReferenceLocation[] => { + const program = info.languageService.getProgram(); + if (!program) return []; + + const isCallExpression = info.config.templateIsCallExpression ?? true; + const references: FragmentReferenceLocation[] = []; + for (const source of program.getSourceFiles()) { + collectFragmentReferencesInSource( + source, + token, + isCallExpression, + info, + references + ); + } + + return references; +}; + +export function getGraphQLFragmentReferences( + filename: string, + cursorPosition: number, + info: ts.server.PluginCreateInfo +): ts.ReferencedSymbol[] | undefined { + const token = getFragmentTokenAtPosition(filename, cursorPosition, info); + if (!token) return undefined; + + const references = findAllFragmentReferences(token, info); + if (!references.length) return undefined; + + const definition = references.find(ref => ref.isDefinition) || references[0]; + + return [ + { + definition: { + fileName: definition.fileName, + textSpan: { start: definition.start, length: definition.length }, + kind: ts.ScriptElementKind.constElement, + name: token.name, + containerKind: ts.ScriptElementKind.unknown, + containerName: '', + displayParts: [{ text: `fragment ${token.name}`, kind: 'text' }], + }, + references: references.map(ref => ({ + fileName: ref.fileName, + textSpan: { start: ref.start, length: ref.length }, + isWriteAccess: ref.isDefinition, + isDefinition: ref.isDefinition, + })), + }, + ]; +} + +export function getGraphQLFragmentReferenceEntries( + filename: string, + cursorPosition: number, + info: ts.server.PluginCreateInfo +): ts.ReferenceEntry[] | undefined { + const token = getFragmentTokenAtPosition(filename, cursorPosition, info); + if (!token) return undefined; + + const references = findAllFragmentReferences(token, info); + if (!references.length) return undefined; + + return references.map(ref => ({ + fileName: ref.fileName, + textSpan: { start: ref.start, length: ref.length }, + isWriteAccess: ref.isDefinition, + })); +} + +export function getGraphQLFragmentRenameInfo( + filename: string, + cursorPosition: number, + info: ts.server.PluginCreateInfo +): ts.RenameInfoSuccess | undefined { + const token = getFragmentTokenAtPosition(filename, cursorPosition, info); + if (!token) return undefined; + + return { + canRename: true, + displayName: token.name, + fullDisplayName: token.name, + kind: ts.ScriptElementKind.constElement, + kindModifiers: ts.ScriptElementKindModifier.none, + triggerSpan: { start: token.start, length: token.length }, + }; +} + +export function getGraphQLFragmentRenameLocations( + filename: string, + cursorPosition: number, + info: ts.server.PluginCreateInfo +): readonly ts.RenameLocation[] | undefined { + const token = getFragmentTokenAtPosition(filename, cursorPosition, info); + if (!token) return undefined; + + const references = findAllFragmentReferences(token, info); + if (!references.length) return undefined; + + return references.map(ref => ({ + fileName: ref.fileName, + textSpan: { start: ref.start, length: ref.length }, + })); +} diff --git a/test/e2e/fixture-project-tada/fixtures/references-fragment.ts b/test/e2e/fixture-project-tada/fixtures/references-fragment.ts new file mode 100644 index 00000000..6cbcfe88 --- /dev/null +++ b/test/e2e/fixture-project-tada/fixtures/references-fragment.ts @@ -0,0 +1,18 @@ +import { graphql } from './graphql'; + +// prettier-ignore +export const ReferencedFields = graphql(` + fragment referencedFields on Pokemon { + id + name + } +`); + +// prettier-ignore +export const ReferencedItemQuery = graphql(` + query ReferencedItem($id: ID!) { + pokemon(id: $id) { + ...referencedFields + } + } +`, [ReferencedFields]); diff --git a/test/e2e/fixture-project-tada/fixtures/references-usage.ts b/test/e2e/fixture-project-tada/fixtures/references-usage.ts new file mode 100644 index 00000000..64567c68 --- /dev/null +++ b/test/e2e/fixture-project-tada/fixtures/references-usage.ts @@ -0,0 +1,12 @@ +import { graphql } from './graphql'; +import { ReferencedFields } from './references-fragment'; + +// prettier-ignore +export const ReferencedListQuery = graphql(` + query ReferencedList($limit: Int!) { + pokemons(limit: $limit) { + ...referencedFields + __typename + } + } +`, [ReferencedFields]); diff --git a/test/e2e/references.test.ts b/test/e2e/references.test.ts new file mode 100644 index 00000000..fc6a5284 --- /dev/null +++ b/test/e2e/references.test.ts @@ -0,0 +1,270 @@ +import { expect, afterAll, beforeAll, it, describe } from 'vitest'; +import { TSServer } from './server'; +import path from 'node:path'; +import fs from 'node:fs'; +import url from 'node:url'; +import ts from 'typescript/lib/tsserverlibrary'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const projectPath = path.resolve(__dirname, 'fixture-project-tada'); + +const outfileFragment = path.join(projectPath, 'references-fragment.ts'); +const outfileUsage = path.join(projectPath, 'references-usage.ts'); + +const fragmentFixture = fs.readFileSync( + path.join(projectPath, 'fixtures/references-fragment.ts'), + 'utf-8' +); +const usageFixture = fs.readFileSync( + path.join(projectPath, 'fixtures/references-usage.ts'), + 'utf-8' +); + +const fragmentName = 'referencedFields'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const positionAt = (text: string, index: number) => { + const before = text.slice(0, index); + const line = before.split('\n').length; + const lastNewline = before.lastIndexOf('\n'); + return { line, offset: index - lastNewline }; +}; + +const indexOf = (text: string, search: string) => { + const index = text.indexOf(search); + if (index === -1) throw new Error(`Could not find "${search}" in fixture`); + return index; +}; + +// Name token offsets of the definition and every spread of the fragment +const definitionIndex = + indexOf(fragmentFixture, `fragment ${fragmentName}`) + 'fragment '.length; +const fragmentSpreadIndex = indexOf(fragmentFixture, `...${fragmentName}`) + 3; +const usageSpreadIndex = indexOf(usageFixture, `...${fragmentName}`) + 3; + +const expectedSpans = [ + { + file: outfileFragment, + start: positionAt(fragmentFixture, definitionIndex), + end: positionAt(fragmentFixture, definitionIndex + fragmentName.length), + isDefinition: true, + }, + { + file: outfileFragment, + start: positionAt(fragmentFixture, fragmentSpreadIndex), + end: positionAt(fragmentFixture, fragmentSpreadIndex + fragmentName.length), + isDefinition: false, + }, + { + file: outfileUsage, + start: positionAt(usageFixture, usageSpreadIndex), + end: positionAt(usageFixture, usageSpreadIndex + fragmentName.length), + isDefinition: false, + }, +]; + +const bySpan = (a: any, b: any) => + a.file.localeCompare(b.file) || + a.start.line - b.start.line || + a.start.offset - b.start.offset; + +const request = async ( + server: TSServer, + command: 'references' | 'rename', + args: Record +) => { + server.send({ type: 'request', command, arguments: args }); + + await server.waitForResponse( + response => response.type === 'response' && response.command === command + ); + + return server.responses + .slice() + .reverse() + .find( + response => response.type === 'response' && response.command === command + ) as ts.server.protocol.Response; +}; + +// The plugin activates asynchronously, so requests are retried until the +// GraphQL-aware result shows up +const waitForResult = async ( + run: () => Promise +): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt < 40; attempt++) { + try { + const result = await run(); + if (result !== undefined) return result; + } catch (error) { + lastError = error; + } + await sleep(250); + } + + throw new Error(`Expected result was not found. Last error: ${lastError}`); +}; + +describe('Fragment references and rename', () => { + let server: TSServer; + + beforeAll(async () => { + server = new TSServer(projectPath, { debugLog: false }); + + server.sendCommand('open', { + file: outfileFragment, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfileUsage, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + + server.sendCommand('updateOpen', { + openFiles: [ + { file: outfileFragment, fileContent: fragmentFixture }, + { file: outfileUsage, fileContent: usageFixture }, + ], + } satisfies ts.server.protocol.UpdateOpenRequestArgs); + + server.sendCommand('saveto', { + file: outfileFragment, + tmpfile: outfileFragment, + } satisfies ts.server.protocol.SavetoRequestArgs); + server.sendCommand('saveto', { + file: outfileUsage, + tmpfile: outfileUsage, + } satisfies ts.server.protocol.SavetoRequestArgs); + }); + + afterAll(() => { + try { + fs.unlinkSync(outfileFragment); + fs.unlinkSync(outfileUsage); + } catch {} + server.close(); + }); + + it('finds all references from the fragment definition', async () => { + const refs = await waitForResult(async () => { + const response = await request(server, 'references', { + file: outfileFragment, + ...positionAt(fragmentFixture, definitionIndex + 2), + }); + const refs = response.body?.refs; + return refs?.length === expectedSpans.length ? refs : undefined; + }); + + const spans = refs + .map((ref: any) => ({ + file: path.normalize(ref.file), + start: ref.start, + end: ref.end, + isDefinition: ref.isDefinition, + })) + .sort(bySpan); + + expect(spans).toEqual([...expectedSpans].sort(bySpan)); + }, 30000); + + it('finds all references from a fragment spread in another file', async () => { + const refs = await waitForResult(async () => { + const response = await request(server, 'references', { + file: outfileUsage, + ...positionAt(usageFixture, usageSpreadIndex + 2), + }); + const refs = response.body?.refs; + return refs?.length === expectedSpans.length ? refs : undefined; + }); + + const spans = refs + .map((ref: any) => ({ + file: path.normalize(ref.file), + start: ref.start, + end: ref.end, + isDefinition: ref.isDefinition, + })) + .sort(bySpan); + + expect(spans).toEqual([...expectedSpans].sort(bySpan)); + }, 30000); + + it('renames a fragment from a spread across all files', async () => { + const body = await waitForResult(async () => { + const response = await request(server, 'rename', { + file: outfileUsage, + ...positionAt(usageFixture, usageSpreadIndex + 2), + }); + return response.body?.info?.canRename ? response.body : undefined; + }); + + expect(body.info.canRename).toBe(true); + expect(body.info.displayName).toBe(fragmentName); + expect(body.info.fullDisplayName).toBe(fragmentName); + expect(body.info.triggerSpan).toEqual({ + start: positionAt(usageFixture, usageSpreadIndex), + end: positionAt(usageFixture, usageSpreadIndex + fragmentName.length), + }); + + const locations = body.locs + .flatMap((group: any) => + group.locs.map((loc: any) => ({ + file: path.normalize(group.file), + start: loc.start, + end: loc.end, + })) + ) + .sort(bySpan); + + expect(locations).toEqual( + [...expectedSpans] + .sort(bySpan) + .map(({ file, start, end }) => ({ file, start, end })) + ); + }, 30000); + + it('renames a fragment from its definition', async () => { + const body = await waitForResult(async () => { + const response = await request(server, 'rename', { + file: outfileFragment, + ...positionAt(fragmentFixture, definitionIndex), + }); + return response.body?.info?.canRename ? response.body : undefined; + }); + + expect(body.info.displayName).toBe(fragmentName); + expect(body.info.triggerSpan).toEqual({ + start: positionAt(fragmentFixture, definitionIndex), + end: positionAt(fragmentFixture, definitionIndex + fragmentName.length), + }); + + const locationCount = body.locs.reduce( + (count: number, group: any) => count + group.locs.length, + 0 + ); + expect(locationCount).toBe(expectedSpans.length); + }, 30000); + + it('falls back to TypeScript references outside GraphQL documents', async () => { + const importIndex = indexOf(usageFixture, 'ReferencedFields'); + + const refs = await waitForResult(async () => { + const response = await request(server, 'references', { + file: outfileUsage, + ...positionAt(usageFixture, importIndex + 2), + }); + const refs = response.body?.refs; + return refs?.length ? refs : undefined; + }); + + // The TypeScript identifier is defined in the fragment file and used in + // the usage file, which proves the original language service still runs + const files = new Set(refs.map((ref: any) => path.normalize(ref.file))); + expect(files).toContain(outfileFragment); + expect(files).toContain(outfileUsage); + }, 30000); +});