Skip to content

Commit 2c73cb7

Browse files
authored
Add suggestions for naming convention rule (#839)
1 parent 9378d24 commit 2c73cb7

File tree

6 files changed

+94
-74
lines changed

6 files changed

+94
-74
lines changed

.changeset/loud-coins-stare.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-eslint/eslint-plugin': minor
3+
---
4+
5+
feat: add suggestions for `naming-convention` rule

packages/plugin/src/rules/match-document-filename.ts

Lines changed: 20 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,14 @@ const MATCH_EXTENSION = 'MATCH_EXTENSION';
99
const MATCH_STYLE = 'MATCH_STYLE';
1010

1111
const ACCEPTED_EXTENSIONS: ['.gql', '.graphql'] = ['.gql', '.graphql'];
12-
const CASE_STYLES: ['camelCase', 'PascalCase', 'snake_case', 'UPPER_CASE', 'kebab-case'] = [
13-
CaseStyle.camelCase,
14-
CaseStyle.pascalCase,
15-
CaseStyle.snakeCase,
16-
CaseStyle.upperCase,
17-
CaseStyle.kebabCase,
18-
];
12+
const CASE_STYLES: CaseStyle[] = ['camelCase', 'PascalCase', 'snake_case', 'UPPER_CASE', 'kebab-case'];
1913

2014
type PropertySchema = {
21-
style: CaseStyle;
22-
suffix: string;
15+
style?: CaseStyle;
16+
suffix?: string;
2317
};
2418

25-
type MatchDocumentFilenameRuleConfig = {
19+
export type MatchDocumentFilenameRuleConfig = {
2620
fileExtension?: typeof ACCEPTED_EXTENSIONS[number];
2721
query?: CaseStyle | PropertySchema;
2822
mutation?: CaseStyle | PropertySchema;
@@ -54,7 +48,7 @@ const rule: GraphQLESLintRule<[MatchDocumentFilenameRuleConfig]> = {
5448
},
5549
{
5650
title: 'Correct',
57-
usage: [{ query: CaseStyle.snakeCase }],
51+
usage: [{ query: 'snake_case' }],
5852
code: /* GraphQL */ `
5953
# user_by_id.gql
6054
query UserById {
@@ -68,7 +62,7 @@ const rule: GraphQLESLintRule<[MatchDocumentFilenameRuleConfig]> = {
6862
},
6963
{
7064
title: 'Correct',
71-
usage: [{ fragment: { style: CaseStyle.kebabCase, suffix: '.fragment' } }],
65+
usage: [{ fragment: { style: 'kebab-case', suffix: '.fragment' } }],
7266
code: /* GraphQL */ `
7367
# user-fields.fragment.gql
7468
fragment user_fields on User {
@@ -79,7 +73,7 @@ const rule: GraphQLESLintRule<[MatchDocumentFilenameRuleConfig]> = {
7973
},
8074
{
8175
title: 'Correct',
82-
usage: [{ mutation: { style: CaseStyle.pascalCase, suffix: 'Mutation' } }],
76+
usage: [{ mutation: { style: 'PascalCase', suffix: 'Mutation' } }],
8377
code: /* GraphQL */ `
8478
# DeleteUserMutation.gql
8579
mutation DELETE_USER {
@@ -99,7 +93,7 @@ const rule: GraphQLESLintRule<[MatchDocumentFilenameRuleConfig]> = {
9993
},
10094
{
10195
title: 'Incorrect',
102-
usage: [{ query: CaseStyle.pascalCase }],
96+
usage: [{ query: 'PascalCase' }],
10397
code: /* GraphQL */ `
10498
# user-by-id.gql
10599
query UserById {
@@ -114,10 +108,10 @@ const rule: GraphQLESLintRule<[MatchDocumentFilenameRuleConfig]> = {
114108
],
115109
configOptions: [
116110
{
117-
query: CaseStyle.kebabCase,
118-
mutation: CaseStyle.kebabCase,
119-
subscription: CaseStyle.kebabCase,
120-
fragment: CaseStyle.kebabCase,
111+
query: 'kebab-case',
112+
mutation: 'kebab-case',
113+
subscription: 'kebab-case',
114+
fragment: 'kebab-case',
121115
},
122116
],
123117
},
@@ -134,25 +128,22 @@ const rule: GraphQLESLintRule<[MatchDocumentFilenameRuleConfig]> = {
134128
asObject: {
135129
type: 'object',
136130
additionalProperties: false,
131+
minProperties: 1,
137132
properties: {
138-
style: {
139-
enum: CASE_STYLES,
140-
},
141-
suffix: {
142-
type: 'string',
143-
},
133+
style: { enum: CASE_STYLES },
134+
suffix: { type: 'string' },
144135
},
145136
},
146137
},
147138
type: 'array',
139+
minItems: 1,
148140
maxItems: 1,
149141
items: {
150142
type: 'object',
151143
additionalProperties: false,
144+
minProperties: 1,
152145
properties: {
153-
fileExtension: {
154-
enum: ACCEPTED_EXTENSIONS,
155-
},
146+
fileExtension: { enum: ACCEPTED_EXTENSIONS },
156147
query: schemaOption,
157148
mutation: schemaOption,
158149
subscription: schemaOption,
@@ -219,7 +210,8 @@ const rule: GraphQLESLintRule<[MatchDocumentFilenameRuleConfig]> = {
219210
option = { style: option } as PropertySchema;
220211
}
221212
const expectedExtension = options.fileExtension || fileExtension;
222-
const expectedFilename = convertCase(option.style, docName) + (option.suffix || '') + expectedExtension;
213+
const expectedFilename =
214+
(option.style ? convertCase(option.style, docName) : filename) + (option.suffix || '') + expectedExtension;
223215
const filenameWithExtension = filename + expectedExtension;
224216

225217
if (expectedFilename !== filenameWithExtension) {

packages/plugin/src/rules/naming-convention.ts

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ASTKindToNode, Kind, NameNode } from 'graphql';
22
import { GraphQLESLintRule, ValueOf } from '../types';
3-
import { TYPES_KINDS, getLocation } from '../utils';
3+
import { TYPES_KINDS, getLocation, convertCase } from '../utils';
44
import { GraphQLESTreeNode } from '../estree-parser';
55
import { GraphQLESLintRuleListener } from '../testkit';
66

@@ -51,7 +51,7 @@ type PropertySchema = {
5151

5252
type Options = AllowedStyle | PropertySchema;
5353

54-
type NamingConventionRuleConfig = {
54+
export type NamingConventionRuleConfig = {
5555
allowLeadingUnderscore?: boolean;
5656
allowTrailingUnderscore?: boolean;
5757
types?: Options;
@@ -165,6 +165,7 @@ const rule: GraphQLESLintRule<[NamingConventionRuleConfig]> = {
165165
],
166166
},
167167
},
168+
hasSuggestions: true,
168169
schema: {
169170
definitions: {
170171
asString: {
@@ -243,69 +244,97 @@ const rule: GraphQLESLintRule<[NamingConventionRuleConfig]> = {
243244
return typeof style === 'object' ? style : { style };
244245
}
245246

246-
const checkNode = (selector: string) => (node: GraphQLESTreeNode<ValueOf<AllowedKindToNode>>) => {
247-
const { name } = node.kind === Kind.VARIABLE_DEFINITION ? node.variable : node;
248-
if (!name) {
247+
const checkNode = (selector: string) => (n: GraphQLESTreeNode<ValueOf<AllowedKindToNode>>) => {
248+
const { name: node } = n.kind === Kind.VARIABLE_DEFINITION ? n.variable : n;
249+
if (!node) {
249250
return;
250251
}
251252
const { prefix, suffix, forbiddenPrefixes, forbiddenSuffixes, style } = normalisePropertyOption(selector);
252-
const nodeType = KindToDisplayName[node.kind] || node.kind;
253-
const nodeName = name.value;
254-
const errorMessage = getErrorMessage();
255-
if (errorMessage) {
253+
const nodeType = KindToDisplayName[n.kind] || n.kind;
254+
const nodeName = node.value;
255+
const error = getError();
256+
if (error) {
257+
const { errorMessage, renameToName } = error;
258+
const [leadingUnderscore] = nodeName.match(/^_*/);
259+
const [trailingUnderscore] = nodeName.match(/_*$/);
260+
const suggestedName = leadingUnderscore + renameToName + trailingUnderscore;
256261
context.report({
257-
loc: getLocation(name.loc, name.value),
262+
loc: getLocation(node.loc, node.value),
258263
message: `${nodeType} "${nodeName}" should ${errorMessage}`,
264+
suggest: [
265+
{
266+
desc: `Rename to "${suggestedName}"`,
267+
fix: fixer => fixer.replaceText(node as any, suggestedName),
268+
},
269+
],
259270
});
260271
}
261272

262-
function getErrorMessage(): string | void {
263-
let name = nodeName;
264-
if (allowLeadingUnderscore) {
265-
name = name.replace(/^_*/, '');
266-
}
267-
if (allowTrailingUnderscore) {
268-
name = name.replace(/_*$/, '');
269-
}
273+
function getError(): {
274+
errorMessage: string;
275+
renameToName: string;
276+
} | void {
277+
const name = nodeName.replace(/(^_+)|(_+$)/g, '');
270278
if (prefix && !name.startsWith(prefix)) {
271-
return `have "${prefix}" prefix`;
279+
return {
280+
errorMessage: `have "${prefix}" prefix`,
281+
renameToName: prefix + name,
282+
};
272283
}
273284
if (suffix && !name.endsWith(suffix)) {
274-
return `have "${suffix}" suffix`;
285+
return {
286+
errorMessage: `have "${suffix}" suffix`,
287+
renameToName: name + suffix,
288+
};
275289
}
276290
const forbiddenPrefix = forbiddenPrefixes?.find(prefix => name.startsWith(prefix));
277291
if (forbiddenPrefix) {
278-
return `not have "${forbiddenPrefix}" prefix`;
292+
return {
293+
errorMessage: `not have "${forbiddenPrefix}" prefix`,
294+
renameToName: name.replace(new RegExp(`^${forbiddenPrefix}`), ''),
295+
};
279296
}
280297
const forbiddenSuffix = forbiddenSuffixes?.find(suffix => name.endsWith(suffix));
281298
if (forbiddenSuffix) {
282-
return `not have "${forbiddenSuffix}" suffix`;
283-
}
284-
if (style && !ALLOWED_STYLES.includes(style)) {
285-
return `be in one of the following options: ${ALLOWED_STYLES.join(', ')}`;
299+
return {
300+
errorMessage: `not have "${forbiddenSuffix}" suffix`,
301+
renameToName: name.replace(new RegExp(`${forbiddenSuffix}$`), ''),
302+
};
286303
}
287304
const caseRegex = StyleToRegex[style];
288305
if (caseRegex && !caseRegex.test(name)) {
289-
return `be in ${style} format`;
306+
return {
307+
errorMessage: `be in ${style} format`,
308+
renameToName: convertCase(style, name),
309+
};
290310
}
291311
}
292312
};
293313

294-
const checkUnderscore = (node: GraphQLESTreeNode<NameNode>) => {
314+
const checkUnderscore = (isLeading: boolean) => (node: GraphQLESTreeNode<NameNode>) => {
295315
const name = node.value;
316+
const renameToName = name.replace(new RegExp(isLeading ? '^_+' : '_+$'), '');
296317
context.report({
297318
loc: getLocation(node.loc, name),
298-
message: `${name.startsWith('_') ? 'Leading' : 'Trailing'} underscores are not allowed`,
319+
message: `${isLeading ? 'Leading' : 'Trailing'} underscores are not allowed`,
320+
suggest: [
321+
{
322+
desc: `Rename to "${renameToName}"`,
323+
fix: fixer => fixer.replaceText(node as any, renameToName),
324+
},
325+
],
299326
});
300327
};
301328

302329
const listeners: GraphQLESLintRuleListener = {};
303330

304331
if (!allowLeadingUnderscore) {
305-
listeners['Name[value=/^_/]:matches([parent.kind!=Field], [parent.kind=Field][parent.alias])'] = checkUnderscore;
332+
listeners['Name[value=/^_/]:matches([parent.kind!=Field], [parent.kind=Field][parent.alias])'] =
333+
checkUnderscore(true);
306334
}
307335
if (!allowTrailingUnderscore) {
308-
listeners['Name[value=/_$/]:matches([parent.kind!=Field], [parent.kind=Field][parent.alias])'] = checkUnderscore;
336+
listeners['Name[value=/_$/]:matches([parent.kind!=Field], [parent.kind=Field][parent.alias])'] =
337+
checkUnderscore(false);
309338
}
310339

311340
const selectors = new Set([types && TYPES_KINDS, Object.keys(restOptions)].flat().filter(Boolean));

packages/plugin/src/utils.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,7 @@ export const TYPES_KINDS = [
155155
Kind.UNION_TYPE_DEFINITION,
156156
] as const;
157157

158-
export enum CaseStyle {
159-
camelCase = 'camelCase',
160-
pascalCase = 'PascalCase',
161-
snakeCase = 'snake_case',
162-
upperCase = 'UPPER_CASE',
163-
kebabCase = 'kebab-case',
164-
}
158+
export type CaseStyle = 'camelCase' | 'PascalCase' | 'snake_case' | 'UPPER_CASE' | 'kebab-case';
165159

166160
const pascalCase = (str: string): string =>
167161
lowerCase(str)
@@ -176,15 +170,15 @@ export const camelCase = (str: string): string => {
176170

177171
export const convertCase = (style: CaseStyle, str: string): string => {
178172
switch (style) {
179-
case CaseStyle.camelCase:
173+
case 'camelCase':
180174
return camelCase(str);
181-
case CaseStyle.pascalCase:
175+
case 'PascalCase':
182176
return pascalCase(str);
183-
case CaseStyle.snakeCase:
177+
case 'snake_case':
184178
return lowerCase(str).replace(/ /g, '_');
185-
case CaseStyle.upperCase:
179+
case 'UPPER_CASE':
186180
return lowerCase(str).replace(/ /g, '_').toUpperCase();
187-
case CaseStyle.kebabCase:
181+
case 'kebab-case':
188182
return lowerCase(str).replace(/ /g, '-');
189183
}
190184
};

packages/plugin/tests/match-document-filename.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { GraphQLRuleTester } from '../src';
2-
import rule from '../src/rules/match-document-filename';
2+
import rule, { MatchDocumentFilenameRuleConfig } from '../src/rules/match-document-filename';
33

44
const ruleTester = new GraphQLRuleTester();
55

6-
ruleTester.runGraphQLTests('match-document-filename', rule, {
6+
ruleTester.runGraphQLTests<[MatchDocumentFilenameRuleConfig]>('match-document-filename', rule, {
77
valid: [
88
{
99
filename: 'src/me.gql',

packages/plugin/tests/naming-convention.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { GraphQLRuleTester } from '../src';
2-
import rule from '../src/rules/naming-convention';
2+
import rule, { NamingConventionRuleConfig } from '../src/rules/naming-convention';
33

44
const ruleTester = new GraphQLRuleTester();
55

6-
ruleTester.runGraphQLTests('naming-convention', rule, {
6+
ruleTester.runGraphQLTests<[NamingConventionRuleConfig]>('naming-convention', rule, {
77
valid: [
88
{
99
code: /* GraphQL */ `

0 commit comments

Comments
 (0)