diff --git a/.changeset/graphql-code-fixes.md b/.changeset/graphql-code-fixes.md new file mode 100644 index 00000000..0cfc6349 --- /dev/null +++ b/.changeset/graphql-code-fixes.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Add quick fixes (code actions) for GraphQLSP's own diagnostics. Misspelled fields, arguments, and types with a "Did you mean" suggestion now offer one fix per suggested replacement, deprecated fields whose deprecation reason names a replacement (for example "Use `newField` instead" or "replaced by `newField`") offer a fix that swaps in the replacement, and unused fields reported by field-usage tracking offer a fix that removes them from the document — unless the removal would leave the parent selection set empty. diff --git a/README.md b/README.md index 574a7e28..bbf50ec1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ auto-complete. - Hover information showing the decriptions of fields - Diagnostics for adding fields that don't exist, are deprecated, missmatched argument types, ... +- Quick fixes for misspelled fields ("Did you mean"), deprecated fields with a known replacement, and unused fields - Auto-complete inside your editor for fields - Will warn you when you are importing from a file that is exporting fragments that you're not using diff --git a/packages/graphqlsp/src/codeFixes.ts b/packages/graphqlsp/src/codeFixes.ts new file mode 100644 index 00000000..1b778c32 --- /dev/null +++ b/packages/graphqlsp/src/codeFixes.ts @@ -0,0 +1,378 @@ +import { ts } from './ts'; +import { parse, visit } from 'graphql'; +import type { FieldNode, SelectionSetNode } from 'graphql'; + +import { findAllCallExpressions, getSource } from './ast'; +import { + SEMANTIC_DIAGNOSTIC_CODE, + USING_DEPRECATED_FIELD_CODE, + getGraphQLDiagnostics, +} from './diagnostics'; +import { UNUSED_FIELD_CODE } from './fieldUsage'; +import { SchemaRef } from './graphql/getSchema'; + +/** Diagnostic codes we can offer a quick fix for. */ +const CODE_FIXABLE_DIAGNOSTICS: readonly number[] = [ + SEMANTIC_DIAGNOSTIC_CODE, + USING_DEPRECATED_FIELD_CODE, + UNUSED_FIELD_CODE, +]; + +/** Computes quick fixes for GraphQLSP's own diagnostics at a position. + * + * Editors pass along the error codes of the diagnostics that overlap the + * requested range; these are matched against the plugin's diagnostics for + * the file (which are LRU-cached by `getGraphQLDiagnostics`, so this + * reuses the results the editor's request is based on). */ +export function getGraphQLCodeFixesAtPosition( + filename: string, + start: number, + end: number, + errorCodes: readonly number[], + schema: SchemaRef, + info: ts.server.PluginCreateInfo +): ts.CodeFixAction[] { + if (!errorCodes.some(code => CODE_FIXABLE_DIAGNOSTICS.includes(code))) { + return []; + } + + const source = getSource(info, filename); + if (!source) return []; + + const diagnostics = getGraphQLDiagnostics(filename, schema, info); + if (!diagnostics) return []; + + const fixes: ts.CodeFixAction[] = []; + for (const diagnostic of diagnostics) { + if ( + diagnostic.start == null || + diagnostic.length == null || + !errorCodes.includes(diagnostic.code) || + // The diagnostic has to overlap the requested range + diagnostic.start > end || + diagnostic.start + diagnostic.length < start + ) { + continue; + } + + const message = ts.flattenDiagnosticMessageText( + diagnostic.messageText, + '\n' + ); + switch (diagnostic.code) { + case SEMANTIC_DIAGNOSTIC_CODE: + fixes.push( + ...getDidYouMeanFixes( + source, + diagnostic.start, + diagnostic.length, + message + ) + ); + break; + case USING_DEPRECATED_FIELD_CODE: + fixes.push( + ...getDeprecatedFieldFixes( + source, + diagnostic.start, + diagnostic.length, + message + ) + ); + break; + case UNUSED_FIELD_CODE: + fixes.push( + ...getUnusedFieldFixes(source, diagnostic.start, message, info) + ); + break; + } + } + + return fixes; +} + +const createFix = ( + fixName: string, + description: string, + fileName: string, + textChanges: ts.TextChange[] +): ts.CodeFixAction => ({ + fixName, + description, + changes: [{ fileName, textChanges }], +}); + +/** Offers one rename-fix per suggestion in a graphql-js validation message + * ending in `Did you mean "a", "b", or "c"?`. */ +const getDidYouMeanFixes = ( + source: ts.SourceFile, + start: number, + length: number, + message: string +): ts.CodeFixAction[] => { + const didYouMeanIndex = message.indexOf('Did you mean '); + if (didYouMeanIndex === -1) return []; + + const suggestionText = message.slice(didYouMeanIndex); + // "Did you mean to use an inline fragment on ..." isn't a plain rename of + // the offending token + if (suggestionText.includes('inline fragment')) return []; + + // The offending token is the first quoted name of the message, e.g. + // `Cannot query field "nam" on type "Pokemon". Did you mean "name"?` + const currentMatch = /"([^"]+)"/.exec(message.slice(0, didYouMeanIndex)); + if (!currentMatch) return []; + const currentName = currentMatch[1]!; + + // The diagnostic's span may cover more than the token itself (e.g. + // trailing whitespace); only replace the token + const spanText = source.getText().slice(start, start + length); + const offsetInSpan = spanText.indexOf(currentName); + if (offsetInSpan === -1) return []; + const span = { start: start + offsetInSpan, length: currentName.length }; + + const fixes: ts.CodeFixAction[] = []; + const suggestionRe = /"([^"]+)"/g; + let match: RegExpExecArray | null; + while ((match = suggestionRe.exec(suggestionText))) { + const suggestion = match[1]!; + fixes.push( + createFix( + 'graphqlDidYouMean', + `Change '${currentName}' to '${suggestion}'`, + source.fileName, + [{ span, newText: suggestion }] + ) + ); + } + return fixes; +}; + +// A replacement in a deprecation reason: a backtick-quoted word or a single +// identifier after "use"/"replaced by"/"replaced with", case-insensitive +const REPLACEMENT_REASON_PATTERNS = [ + /\b(?:use|replaced\s+(?:by|with))\s+`([A-Za-z_][A-Za-z0-9_]*)`/i, + /\b(?:use|replaced\s+(?:by|with))\s+"([A-Za-z_][A-Za-z0-9_]*)"/i, + /\b(?:use|replaced\s+(?:by|with))\s+'([A-Za-z_][A-Za-z0-9_]*)'/i, + /\b(?:use|replaced\s+(?:by|with))\s+([A-Za-z_][A-Za-z0-9_]*)/i, +]; + +/** Offers replacing a deprecated field when its deprecation reason names a + * replacement, e.g. "Use `newField` instead" or "replaced by newField". + * Without a parseable replacement, no fix is offered. */ +const getDeprecatedFieldFixes = ( + source: ts.SourceFile, + start: number, + length: number, + message: string +): ts.CodeFixAction[] => { + const match = /^The field "?[\w.]+\.(\w+)"? is deprecated\.?\s*(.*)$/.exec( + message + ); + if (!match) return []; + const fieldName = match[1]!; + const reason = match[2] || ''; + + let replacement: string | undefined; + for (const pattern of REPLACEMENT_REASON_PATTERNS) { + const reasonMatch = pattern.exec(reason); + if (reasonMatch) { + replacement = reasonMatch[1]; + break; + } + } + if (!replacement || replacement === fieldName) return []; + + // The deprecated-field diagnostic's span extends past the field name; + // only replace the name itself + const spanText = source.getText().slice(start, start + length); + const offsetInSpan = spanText.indexOf(fieldName); + if (offsetInSpan === -1) return []; + + return [ + createFix( + 'graphqlReplaceDeprecatedField', + `Replace deprecated field '${fieldName}' with '${replacement}'`, + source.fileName, + [ + { + span: { start: start + offsetInSpan, length: fieldName.length }, + newText: replacement, + }, + ] + ), + ]; +}; + +/** Offers removing the field(s) an unused-field diagnostic reports. The + * diagnostic's span points at the parent field, while the unused leaf paths + * are listed in its message; each path is resolved back to its field node in + * the document to delete exactly that field's text. When the removal would + * leave a selection set empty (which is invalid GraphQL) no fix is offered. */ +const getUnusedFieldFixes = ( + source: ts.SourceFile, + diagnosticStart: number, + message: string, + info: ts.server.PluginCreateInfo +): ts.CodeFixAction[] => { + const listMatch = + /^Field\(s\) (.+) are not used\.$/.exec(message) || + /^Field (.+) is not used\.$/.exec(message); + if (!listMatch) return []; + const paths = listMatch[1]! + .split(', ') + .map(path => path.replace(/^'|'$/g, '')); + + // Locate the GraphQL document the diagnostic points into + const { nodes } = findAllCallExpressions(source, info, { + searchExternal: false, + collectFragments: false, + }); + const found = nodes.find( + ({ node }) => + diagnosticStart >= node.getStart() && diagnosticStart <= node.getEnd() + ); + if (!found) return []; + const template = found.node; + const text = template.getText().slice(1, -1); + + let document; + try { + document = parse(text); + } catch (_error) { + return []; + } + + // Index the document's fields by response-shape path (the alias when one + // is present), mirroring how field usage tracking builds its paths + const fieldByPath = new Map< + string, + { field: FieldNode; parent: SelectionSetNode } + >(); + const inProgress: string[] = []; + const selectionSetStack: SelectionSetNode[] = []; + visit(document, { + SelectionSet: { + enter(selectionSet) { + selectionSetStack.push(selectionSet); + }, + leave() { + selectionSetStack.pop(); + }, + }, + Field: { + enter(fieldNode) { + const alias = fieldNode.alias + ? fieldNode.alias.value + : fieldNode.name.value; + const path = inProgress.length + ? `${inProgress.join('.')}.${alias}` + : alias; + const parent = selectionSetStack[selectionSetStack.length - 1]!; + fieldByPath.set(path, { field: fieldNode, parent }); + if (fieldNode.selectionSet) inProgress.push(alias); + }, + leave(fieldNode) { + if (fieldNode.selectionSet) inProgress.pop(); + }, + }, + }); + + const entries: Array<{ field: FieldNode; parent: SelectionSetNode }> = []; + for (const path of paths) { + const entry = fieldByPath.get(path); + if (!entry || !entry.field.loc) return []; + entries.push(entry); + } + + // Deleting every selection of a set would produce invalid GraphQL + const removedPerParent = new Map(); + for (const entry of entries) { + removedPerParent.set( + entry.parent, + (removedPerParent.get(entry.parent) || 0) + 1 + ); + } + for (const [parent, removed] of removedPerParent) { + if (parent.selections.length <= removed) return []; + } + + // GraphQL document offsets map onto the file with a one-character shift + // for the template literal's opening quote/backtick + const templateStart = template.getStart() + 1; + const textChanges: ts.TextChange[] = entries.map(({ field }) => { + const deletion = expandFieldDeletion( + text, + field.loc!.start, + field.loc!.end + ); + return { + span: { + start: templateStart + deletion.start, + length: deletion.end - deletion.start, + }, + newText: '', + }; + }); + textChanges.sort((a, b) => a.span.start - b.span.start); + + const description = + paths.length > 1 + ? `Remove unused fields ${paths.map(path => `'${path}'`).join(', ')}` + : `Remove unused field '${paths[0]}'`; + return [ + createFix( + 'graphqlRemoveUnusedField', + description, + source.fileName, + textChanges + ), + ]; +}; + +/** Expands a field's deletion range over the whitespace it leaves behind: + * fields that own their line(s) take the indentation and trailing newline + * with them, fields sharing a line only take their separating whitespace. */ +const expandFieldDeletion = ( + text: string, + start: number, + end: number +): { start: number; end: number } => { + // Trailing spaces and commas (commas are insignificant in GraphQL) + let expandedEnd = end; + while ( + text[expandedEnd] === ' ' || + text[expandedEnd] === '\t' || + text[expandedEnd] === ',' + ) { + expandedEnd++; + } + + // Leading indentation, up to the preceding newline + let expandedStart = start; + while ( + expandedStart > 0 && + (text[expandedStart - 1] === ' ' || text[expandedStart - 1] === '\t') + ) { + expandedStart--; + } + + const atLineStart = expandedStart === 0 || text[expandedStart - 1] === '\n'; + const atLineEnd = + expandedEnd >= text.length || + text[expandedEnd] === '\n' || + text[expandedEnd] === '\r'; + + if (atLineStart && atLineEnd) { + // The field owns its line(s): delete them entirely + if (text[expandedEnd] === '\r') expandedEnd++; + if (text[expandedEnd] === '\n') expandedEnd++; + return { start: expandedStart, end: expandedEnd }; + } else if (atLineStart) { + // Content follows on the same line: keep the indentation in place + return { start, end: expandedEnd }; + } else { + // The field trails other content: only take the separating whitespace + return { start: expandedStart, end }; + } +}; diff --git a/packages/graphqlsp/src/index.ts b/packages/graphqlsp/src/index.ts index bfe33065..7a607ed0 100644 --- a/packages/graphqlsp/src/index.ts +++ b/packages/graphqlsp/src/index.ts @@ -9,6 +9,7 @@ import { getGraphQLDefinitionAtPosition, } from './definition'; import { ALL_DIAGNOSTICS, getGraphQLDiagnostics } from './diagnostics'; +import { getGraphQLCodeFixesAtPosition } from './codeFixes'; import { templates } from './ast/templates'; import { getPersistedCodeFixAtPosition } from './persisted'; @@ -91,6 +92,39 @@ function create(info: ts.server.PluginCreateInfo) { }); }; + proxy.getCodeFixesAtPosition = ( + filename: string, + start: number, + end: number, + errorCodes: readonly number[], + formatOptions: ts.FormatCodeSettings, + preferences: ts.UserPreferences + ): readonly ts.CodeFixAction[] => { + const originalFixes = info.languageService.getCodeFixesAtPosition( + filename, + start, + end, + errorCodes, + formatOptions, + preferences + ); + + return guard('getCodeFixesAtPosition', originalFixes, () => { + const graphQLFixes = getGraphQLCodeFixesAtPosition( + filename, + start, + end, + errorCodes, + schema, + info + ); + + return graphQLFixes.length + ? [...graphQLFixes, ...originalFixes] + : originalFixes; + }); + }; + proxy.getCompletionsAtPosition = ( filename: string, cursorPosition: number, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f7b8416..72b9387a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,19 @@ importers: specifier: ^5.3.3 version: 5.3.3 + test/e2e/fixture-project-code-fixes: + dependencies: + '@0no-co/graphqlsp': + specifier: workspace:* + version: link:../../../packages/graphqlsp + graphql: + specifier: ^16.0.0 + version: 16.8.1 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.3.3 + test/e2e/fixture-project-misconfiguration: dependencies: '@0no-co/graphqlsp': @@ -367,12 +380,6 @@ packages: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 typescript: ^5.3.3 - '@0no-co/graphqlsp@file:packages/graphqlsp': - resolution: {directory: packages/graphqlsp, type: directory} - peerDependencies: - graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 - typescript: ^5.3.3 - '@actions/core@1.10.0': resolution: {integrity: sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==} @@ -736,7 +743,6 @@ packages: '@gql.tada/cli-utils@1.9.0': resolution: {integrity: sha512-sTlFYC4dFxbJtLwmA43qzt/fo5SHJ4PWFmj45ILNuslZ/0E3Wpb436ll63G5EBFG+Dx/SWMe2S+epVw9rchQVA==} - version: 1.9.0 peerDependencies: '@0no-co/graphqlsp': ^1.16.0 '@gql.tada/svelte-support': 1.0.3 @@ -3157,12 +3163,6 @@ snapshots: graphql: 16.8.1 typescript: 5.3.3 - '@0no-co/graphqlsp@file:packages/graphqlsp(graphql@16.8.1)(typescript@5.3.3)': - dependencies: - '@gql.tada/internal': 1.2.0(graphql@16.8.1)(typescript@5.3.3) - graphql: 16.8.1 - typescript: 5.3.3 - '@actions/core@1.10.0': dependencies: '@actions/http-client': 2.2.3 @@ -3616,9 +3616,9 @@ snapshots: '@fastify/busboy@3.2.0': {} - '@gql.tada/cli-utils@1.9.0(@0no-co/graphqlsp@file:packages/graphqlsp(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(typescript@5.3.3)': + '@gql.tada/cli-utils@1.9.0(@0no-co/graphqlsp@1.17.1(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(typescript@5.3.3)': dependencies: - '@0no-co/graphqlsp': file:packages/graphqlsp(graphql@16.8.1)(typescript@5.3.3) + '@0no-co/graphqlsp': 1.17.1(graphql@16.8.1)(typescript@5.3.3) '@gql.tada/internal': 1.2.0(graphql@16.8.1)(typescript@5.3.3) graphql: 16.8.1 typescript: 5.3.3 @@ -5077,7 +5077,7 @@ snapshots: dependencies: '@0no-co/graphql.web': 1.3.2(graphql@16.8.1) '@0no-co/graphqlsp': 1.17.1(graphql@16.8.1)(typescript@5.3.3) - '@gql.tada/cli-utils': 1.9.0(@0no-co/graphqlsp@file:packages/graphqlsp(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(typescript@5.3.3) + '@gql.tada/cli-utils': 1.9.0(@0no-co/graphqlsp@1.17.1(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(typescript@5.3.3) '@gql.tada/internal': 1.2.0(graphql@16.8.1)(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: diff --git a/test/e2e/code-fixes.test.ts b/test/e2e/code-fixes.test.ts new file mode 100644 index 00000000..92458aa7 --- /dev/null +++ b/test/e2e/code-fixes.test.ts @@ -0,0 +1,398 @@ +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-code-fixes'); +describe('Code fixes', () => { + const outfileDidYouMean = path.join(projectPath, 'did-you-mean.ts'); + const outfileDeprecated = path.join(projectPath, 'deprecated.ts'); + + let server: TSServer; + + const isCodeFixResponse = (response: any) => + response.type === 'response' && response.command === 'getCodeFixes'; + + const requestCodeFixes = async ( + args: ts.server.protocol.CodeFixRequestArgs + ): Promise => { + const seen = server.responses.filter(isCodeFixResponse).length; + server.sendCommand('getCodeFixes', args); + await server.waitForResponse( + response => + isCodeFixResponse(response) && + server.responses.filter(isCodeFixResponse).length > seen + ); + const responses = server.responses.filter(isCodeFixResponse); + const body = responses[responses.length - 1].body || []; + // Snapshots have to stay machine-independent + return body.map((fix: any) => ({ + ...fix, + changes: fix.changes.map((change: any) => ({ + ...change, + fileName: path.relative(projectPath, change.fileName), + })), + })); + }; + + beforeAll(async () => { + server = new TSServer(projectPath, { debugLog: false }); + + server.sendCommand('open', { + file: outfileDidYouMean, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfileDeprecated, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + + server.sendCommand('updateOpen', { + openFiles: [ + { + file: outfileDidYouMean, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/did-you-mean.ts'), + 'utf-8' + ), + }, + { + file: outfileDeprecated, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/deprecated.ts'), + 'utf-8' + ), + }, + ], + } satisfies ts.server.protocol.UpdateOpenRequestArgs); + + server.sendCommand('saveto', { + file: outfileDidYouMean, + tmpfile: outfileDidYouMean, + } satisfies ts.server.protocol.SavetoRequestArgs); + server.sendCommand('saveto', { + file: outfileDeprecated, + tmpfile: outfileDeprecated, + } satisfies ts.server.protocol.SavetoRequestArgs); + }); + + afterAll(() => { + try { + fs.unlinkSync(outfileDidYouMean); + fs.unlinkSync(outfileDeprecated); + } catch {} + server.close(); + }); + + it('gives quick fixes for "Did you mean" suggestions', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfileDidYouMean, + true + ); + + const diagnostics = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfileDidYouMean + ); + expect(diagnostics[0].body.diagnostics.filter((x: any) => x.code === 52001)) + .toMatchInlineSnapshot(` + [ + { + "category": "error", + "code": 52001, + "end": { + "line": 9, + "offset": 1, + }, + "start": { + "line": 8, + "offset": 7, + }, + "text": "Cannot query field \\"nam\\" on type \\"Pokemon\\". Did you mean \\"name\\"?", + }, + { + "category": "error", + "code": 52001, + "end": { + "line": 16, + "offset": 12, + }, + "start": { + "line": 16, + "offset": 5, + }, + "text": "Cannot query field \\"pokemo\\" on type \\"Query\\". Did you mean \\"pokemon\\" or \\"pokemons\\"?", + }, + ] + `); + + const fixes = await requestCodeFixes({ + file: outfileDidYouMean, + startLine: 8, + startOffset: 7, + endLine: 8, + endOffset: 10, + errorCodes: [52001], + }); + + expect(fixes).toMatchInlineSnapshot(` + [ + { + "changes": [ + { + "fileName": "did-you-mean.ts", + "textChanges": [ + { + "end": { + "line": 8, + "offset": 10, + }, + "newText": "name", + "start": { + "line": 8, + "offset": 7, + }, + }, + ], + }, + ], + "description": "Change 'nam' to 'name'", + "fixName": "graphqlDidYouMean", + }, + ] + `); + }, 30000); + + it('gives one quick fix per "Did you mean" suggestion', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfileDidYouMean, + true + ); + + const fixes = await requestCodeFixes({ + file: outfileDidYouMean, + startLine: 16, + startOffset: 5, + endLine: 16, + endOffset: 11, + errorCodes: [52001], + }); + + expect(fixes).toMatchInlineSnapshot(` + [ + { + "changes": [ + { + "fileName": "did-you-mean.ts", + "textChanges": [ + { + "end": { + "line": 16, + "offset": 11, + }, + "newText": "pokemon", + "start": { + "line": 16, + "offset": 5, + }, + }, + ], + }, + ], + "description": "Change 'pokemo' to 'pokemon'", + "fixName": "graphqlDidYouMean", + }, + { + "changes": [ + { + "fileName": "did-you-mean.ts", + "textChanges": [ + { + "end": { + "line": 16, + "offset": 11, + }, + "newText": "pokemons", + "start": { + "line": 16, + "offset": 5, + }, + }, + ], + }, + ], + "description": "Change 'pokemo' to 'pokemons'", + "fixName": "graphqlDidYouMean", + }, + ] + `); + }, 30000); + + it('gives a quick fix replacing a deprecated field when the reason names a replacement', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfileDeprecated, + true + ); + + const diagnostics = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfileDeprecated + ); + expect(diagnostics[0].body.diagnostics.filter((x: any) => x.code === 52004)) + .toMatchInlineSnapshot(` + [ + { + "category": "warning", + "code": 52004, + "end": { + "line": 9, + "offset": 1, + }, + "start": { + "line": 8, + "offset": 7, + }, + "text": "The field Pokemon.nickname is deprecated. Use \`name\` instead", + }, + { + "category": "warning", + "code": 52004, + "end": { + "line": 10, + "offset": 1, + }, + "start": { + "line": 9, + "offset": 7, + }, + "text": "The field Pokemon.level is deprecated. This field is replaced by \`power\`", + }, + { + "category": "warning", + "code": 52004, + "end": { + "line": 11, + "offset": 1, + }, + "start": { + "line": 10, + "offset": 7, + }, + "text": "The field Pokemon.fleeRate is deprecated. No longer supported", + }, + ] + `); + + // "Use `name` instead" + const backtickedUse = await requestCodeFixes({ + file: outfileDeprecated, + startLine: 8, + startOffset: 7, + endLine: 8, + endOffset: 15, + errorCodes: [52004], + }); + expect(backtickedUse).toMatchInlineSnapshot(` + [ + { + "changes": [ + { + "fileName": "deprecated.ts", + "textChanges": [ + { + "end": { + "line": 8, + "offset": 15, + }, + "newText": "name", + "start": { + "line": 8, + "offset": 7, + }, + }, + ], + }, + ], + "description": "Replace deprecated field 'nickname' with 'name'", + "fixName": "graphqlReplaceDeprecatedField", + }, + ] + `); + + // "This field is replaced by `power`" + const replacedBy = await requestCodeFixes({ + file: outfileDeprecated, + startLine: 9, + startOffset: 7, + endLine: 9, + endOffset: 12, + errorCodes: [52004], + }); + expect(replacedBy).toMatchInlineSnapshot(` + [ + { + "changes": [ + { + "fileName": "deprecated.ts", + "textChanges": [ + { + "end": { + "line": 9, + "offset": 12, + }, + "newText": "power", + "start": { + "line": 9, + "offset": 7, + }, + }, + ], + }, + ], + "description": "Replace deprecated field 'level' with 'power'", + "fixName": "graphqlReplaceDeprecatedField", + }, + ] + `); + }, 30000); + + it('gives no quick fix when a deprecation reason has no replacement', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfileDeprecated, + true + ); + + // "No longer supported" + const fixes = await requestCodeFixes({ + file: outfileDeprecated, + startLine: 10, + startOffset: 7, + endLine: 10, + endOffset: 15, + errorCodes: [52004], + }); + expect(fixes).toEqual([]); + }, 30000); +}); diff --git a/test/e2e/fixture-project-code-fixes/fixtures/deprecated.ts b/test/e2e/fixture-project-code-fixes/fixtures/deprecated.ts new file mode 100644 index 00000000..40702948 --- /dev/null +++ b/test/e2e/fixture-project-code-fixes/fixtures/deprecated.ts @@ -0,0 +1,15 @@ +import { graphql } from './graphql'; + +// prettier-ignore +const deprecatedFields = graphql(` + query Pok { + pokemons { + id + nickname + level + fleeRate + } + } +`); + +console.log(deprecatedFields); diff --git a/test/e2e/fixture-project-code-fixes/fixtures/did-you-mean.ts b/test/e2e/fixture-project-code-fixes/fixtures/did-you-mean.ts new file mode 100644 index 00000000..672334ba --- /dev/null +++ b/test/e2e/fixture-project-code-fixes/fixtures/did-you-mean.ts @@ -0,0 +1,22 @@ +import { graphql } from './graphql'; + +// prettier-ignore +const misspelledField = graphql(` + query Pok { + pokemons { + id + nam + } + } +`); + +// prettier-ignore +const misspelledTopLevelField = graphql(` + query Pok { + pokemo { + id + } + } +`); + +console.log(misspelledField, misspelledTopLevelField); diff --git a/test/e2e/fixture-project-code-fixes/fixtures/graphql.ts b/test/e2e/fixture-project-code-fixes/fixtures/graphql.ts new file mode 100644 index 00000000..0b281faf --- /dev/null +++ b/test/e2e/fixture-project-code-fixes/fixtures/graphql.ts @@ -0,0 +1,5 @@ +import type { DocumentNode } from 'graphql'; + +export function graphql(document: string): DocumentNode { + return document as any; +} diff --git a/test/e2e/fixture-project-code-fixes/package.json b/test/e2e/fixture-project-code-fixes/package.json new file mode 100644 index 00000000..693a6af1 --- /dev/null +++ b/test/e2e/fixture-project-code-fixes/package.json @@ -0,0 +1,11 @@ +{ + "name": "fixtures-code-fixes", + "private": true, + "dependencies": { + "graphql": "^16.0.0", + "@0no-co/graphqlsp": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/test/e2e/fixture-project-code-fixes/schema.graphql b/test/e2e/fixture-project-code-fixes/schema.graphql new file mode 100644 index 00000000..e8d4d5ef --- /dev/null +++ b/test/e2e/fixture-project-code-fixes/schema.graphql @@ -0,0 +1,19 @@ +type Attack { + name: String + damage: Int +} + +type Pokemon { + id: ID! + name: String! + nickname: String @deprecated(reason: "Use `name` instead") + level: Int @deprecated(reason: "This field is replaced by `power`") + power: Int + fleeRate: Float @deprecated(reason: "No longer supported") + attacks: [Attack] +} + +type Query { + pokemon(id: ID!): Pokemon + pokemons(limit: Int): [Pokemon] +} diff --git a/test/e2e/fixture-project-code-fixes/tsconfig.json b/test/e2e/fixture-project-code-fixes/tsconfig.json new file mode 100644 index 00000000..e22c7b13 --- /dev/null +++ b/test/e2e/fixture-project-code-fixes/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "plugins": [ + { + "name": "@0no-co/graphqlsp", + "schema": "./schema.graphql", + "trackFieldUsage": false, + "shouldCheckForColocatedFragments": false + } + ], + "target": "es2016", + "esModuleInterop": true, + "moduleResolution": "node", + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", "fixtures"] +} diff --git a/test/e2e/unused-fieds.test.ts b/test/e2e/unused-fieds.test.ts index 8a67696d..1bffb177 100644 --- a/test/e2e/unused-fieds.test.ts +++ b/test/e2e/unused-fieds.test.ts @@ -25,6 +25,32 @@ describe('unused fields', () => { const outfileMultiDocument = path.join(projectPath, 'multi-document.ts'); let server: TSServer; + + const isCodeFixResponse = (response: any) => + response.type === 'response' && response.command === 'getCodeFixes'; + + const requestCodeFixes = async ( + args: ts.server.protocol.CodeFixRequestArgs + ): Promise => { + const seen = server.responses.filter(isCodeFixResponse).length; + server.sendCommand('getCodeFixes', args); + await server.waitForResponse( + response => + isCodeFixResponse(response) && + server.responses.filter(isCodeFixResponse).length > seen + ); + const responses = server.responses.filter(isCodeFixResponse); + const body = responses[responses.length - 1].body || []; + // Snapshots have to stay machine-independent + return body.map((fix: any) => ({ + ...fix, + changes: fix.changes.map((change: any) => ({ + ...change, + fileName: path.relative(projectPath, change.fileName), + })), + })); + }; + beforeAll(async () => { server = new TSServer(projectPath, { debugLog: false }); @@ -610,4 +636,121 @@ describe('unused fields', () => { text: "Field(s) 'pokemons.fleeRate' are not used.", }); }, 30000); + + it('gives a quick fix removing an unused field', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfilePropAccess, + true + ); + + // "Field(s) 'pokemon.fleeRate' are not used." reported on `pokemon` + const fixes = await requestCodeFixes({ + file: outfilePropAccess, + startLine: 9, + startOffset: 5, + endLine: 9, + endOffset: 12, + errorCodes: [52005], + }); + + expect(fixes).toMatchInlineSnapshot(` + [ + { + "changes": [ + { + "fileName": "property-access.tsx", + "textChanges": [ + { + "end": { + "line": 12, + "offset": 1, + }, + "newText": "", + "start": { + "line": 11, + "offset": 1, + }, + }, + ], + }, + ], + "description": "Remove unused field 'pokemon.fleeRate'", + "fixName": "graphqlRemoveUnusedField", + }, + ] + `); + }, 30000); + + it('gives a quick fix removing a nested unused field', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfilePropAccess, + true + ); + + // "Field(s) 'pokemon.attacks.special.damage' are not used." reported on `special` + const fixes = await requestCodeFixes({ + file: outfilePropAccess, + startLine: 14, + startOffset: 9, + endLine: 14, + endOffset: 16, + errorCodes: [52005], + }); + + expect(fixes).toMatchInlineSnapshot(` + [ + { + "changes": [ + { + "fileName": "property-access.tsx", + "textChanges": [ + { + "end": { + "line": 17, + "offset": 1, + }, + "newText": "", + "start": { + "line": 16, + "offset": 1, + }, + }, + ], + }, + ], + "description": "Remove unused field 'pokemon.attacks.special.damage'", + "fixName": "graphqlRemoveUnusedField", + }, + ] + `); + }, 30000); + + it('gives no quick fix when removing the unused fields would empty the selection set', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfilePropAccess, + true + ); + + // "Field(s) 'pokemon.weight.minimum', 'pokemon.weight.maximum' are not + // used." reported on `weight`, whose selection set has no other fields + const fixes = await requestCodeFixes({ + file: outfilePropAccess, + startLine: 19, + startOffset: 7, + endLine: 19, + endOffset: 13, + errorCodes: [52005], + }); + + expect(fixes).toEqual([]); + }, 30000); });