@graphql-eslint/eslint-plugin
Version:
GraphQL plugin for ESLint
308 lines (307 loc) • 12.6 kB
JavaScript
import { Kind } from 'graphql';
import { TYPES_KINDS, convertCase, ARRAY_DEFAULT_OPTIONS } from '../utils.js';
const KindToDisplayName = {
// types
[Kind.OBJECT_TYPE_DEFINITION]: 'Type',
[Kind.INTERFACE_TYPE_DEFINITION]: 'Interface',
[Kind.ENUM_TYPE_DEFINITION]: 'Enumerator',
[Kind.SCALAR_TYPE_DEFINITION]: 'Scalar',
[Kind.INPUT_OBJECT_TYPE_DEFINITION]: 'Input type',
[Kind.UNION_TYPE_DEFINITION]: 'Union',
// fields
[Kind.FIELD_DEFINITION]: 'Field',
[Kind.INPUT_VALUE_DEFINITION]: 'Input property',
[Kind.ARGUMENT]: 'Argument',
[Kind.DIRECTIVE_DEFINITION]: 'Directive',
// rest
[Kind.ENUM_VALUE_DEFINITION]: 'Enumeration value',
[Kind.OPERATION_DEFINITION]: 'Operation',
[Kind.FRAGMENT_DEFINITION]: 'Fragment',
[Kind.VARIABLE_DEFINITION]: 'Variable',
};
const StyleToRegex = {
camelCase: /^[a-z][\dA-Za-z]*$/,
PascalCase: /^[A-Z][\dA-Za-z]*$/,
snake_case: /^[a-z][\d_a-z]*[\da-z]*$/,
UPPER_CASE: /^[A-Z][\dA-Z_]*[\dA-Z]*$/,
};
const ALLOWED_KINDS = Object.keys(KindToDisplayName).sort();
const ALLOWED_STYLES = Object.keys(StyleToRegex);
const schemaOption = {
oneOf: [{ $ref: '#/definitions/asString' }, { $ref: '#/definitions/asObject' }],
};
const schema = {
definitions: {
asString: {
enum: ALLOWED_STYLES,
description: `One of: ${ALLOWED_STYLES.map(t => `\`${t}\``).join(', ')}`,
},
asObject: {
type: 'object',
additionalProperties: false,
properties: {
style: { enum: ALLOWED_STYLES },
prefix: { type: 'string' },
suffix: { type: 'string' },
forbiddenPrefixes: ARRAY_DEFAULT_OPTIONS,
forbiddenSuffixes: ARRAY_DEFAULT_OPTIONS,
ignorePattern: {
type: 'string',
description: 'Option to skip validation of some words, e.g. acronyms',
},
},
},
},
type: 'array',
maxItems: 1,
items: {
type: 'object',
additionalProperties: false,
properties: {
types: {
...schemaOption,
description: `Includes:\n${TYPES_KINDS.map(kind => `- \`${kind}\``).join('\n')}`,
},
...Object.fromEntries(ALLOWED_KINDS.map(kind => [
kind,
{
...schemaOption,
description: `Read more about this kind on [spec.graphql.org](https://spec.graphql.org/October2021/#${kind}).`,
},
])),
allowLeadingUnderscore: {
type: 'boolean',
default: false,
},
allowTrailingUnderscore: {
type: 'boolean',
default: false,
},
},
patternProperties: {
[`^(${ALLOWED_KINDS.join('|')})(.+)?$`]: schemaOption,
},
description: [
"> It's possible to use a [`selector`](https://eslint.org/docs/developer-guide/selectors) that starts with allowed `ASTNode` names which are described below.",
'>',
'> Paste or drop code into the editor in [ASTExplorer](https://astexplorer.net) and inspect the generated AST to compose your selector.',
'>',
'> Example: pattern property `FieldDefinition[parent.name.value=Query]` will match only fields for type `Query`.',
].join('\n'),
},
};
export const rule = {
meta: {
type: 'suggestion',
docs: {
description: 'Require names to follow specified conventions.',
category: ['Schema', 'Operations'],
recommended: true,
url: 'https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/naming-convention.md',
examples: [
{
title: 'Incorrect',
usage: [{ types: 'PascalCase', FieldDefinition: 'camelCase' }],
code: /* GraphQL */ `
type user {
first_name: String!
}
`,
},
{
title: 'Incorrect',
usage: [{ FragmentDefinition: { style: 'PascalCase', forbiddenSuffixes: ['Fragment'] } }],
code: /* GraphQL */ `
fragment UserFragment on User {
# ...
}
`,
},
{
title: 'Incorrect',
usage: [{ 'FieldDefinition[parent.name.value=Query]': { forbiddenPrefixes: ['get'] } }],
code: /* GraphQL */ `
type Query {
getUsers: [User!]!
}
`,
},
{
title: 'Correct',
usage: [{ types: 'PascalCase', FieldDefinition: 'camelCase' }],
code: /* GraphQL */ `
type User {
firstName: String
}
`,
},
{
title: 'Correct',
usage: [{ FragmentDefinition: { style: 'PascalCase', forbiddenSuffixes: ['Fragment'] } }],
code: /* GraphQL */ `
fragment UserFields on User {
# ...
}
`,
},
{
title: 'Correct',
usage: [{ 'FieldDefinition[parent.name.value=Query]': { forbiddenPrefixes: ['get'] } }],
code: /* GraphQL */ `
type Query {
users: [User!]!
}
`,
},
{
title: 'Correct',
usage: [{ FieldDefinition: { style: 'camelCase', ignorePattern: '^(EAN13|UPC|UK)' } }],
code: /* GraphQL */ `
type Product {
EAN13: String
UPC: String
UKFlag: String
}
`,
},
],
configOptions: {
schema: [
{
types: 'PascalCase',
FieldDefinition: 'camelCase',
InputValueDefinition: 'camelCase',
Argument: 'camelCase',
DirectiveDefinition: 'camelCase',
EnumValueDefinition: 'UPPER_CASE',
'FieldDefinition[parent.name.value=Query]': {
forbiddenPrefixes: ['query', 'get'],
forbiddenSuffixes: ['Query'],
},
'FieldDefinition[parent.name.value=Mutation]': {
forbiddenPrefixes: ['mutation'],
forbiddenSuffixes: ['Mutation'],
},
'FieldDefinition[parent.name.value=Subscription]': {
forbiddenPrefixes: ['subscription'],
forbiddenSuffixes: ['Subscription'],
},
},
],
operations: [
{
VariableDefinition: 'camelCase',
OperationDefinition: {
style: 'PascalCase',
forbiddenPrefixes: ['Query', 'Mutation', 'Subscription', 'Get'],
forbiddenSuffixes: ['Query', 'Mutation', 'Subscription'],
},
FragmentDefinition: {
style: 'PascalCase',
forbiddenPrefixes: ['Fragment'],
forbiddenSuffixes: ['Fragment'],
},
},
],
},
},
hasSuggestions: true,
schema,
},
create(context) {
const options = context.options[0] || {};
const { allowLeadingUnderscore, allowTrailingUnderscore, types, ...restOptions } = options;
function normalisePropertyOption(kind) {
const style = restOptions[kind] || types;
return typeof style === 'object' ? style : { style };
}
function report(node, message, suggestedName) {
context.report({
node,
message,
suggest: [
{
desc: `Rename to \`${suggestedName}\``,
fix: fixer => fixer.replaceText(node, suggestedName),
},
],
});
}
const checkNode = (selector) => (n) => {
const { name: node } = n.kind === Kind.VARIABLE_DEFINITION ? n.variable : n;
if (!node) {
return;
}
const { prefix, suffix, forbiddenPrefixes, forbiddenSuffixes, style, ignorePattern } = normalisePropertyOption(selector);
const nodeType = KindToDisplayName[n.kind] || n.kind;
const nodeName = node.value;
const error = getError();
if (error) {
const { errorMessage, renameToName } = error;
const [leadingUnderscores] = nodeName.match(/^_*/);
const [trailingUnderscores] = nodeName.match(/_*$/);
const suggestedName = leadingUnderscores + renameToName + trailingUnderscores;
report(node, `${nodeType} "${nodeName}" should ${errorMessage}`, suggestedName);
}
function getError() {
const name = nodeName.replace(/(^_+)|(_+$)/g, '');
if (ignorePattern && new RegExp(ignorePattern, 'u').test(name)) {
return;
}
if (prefix && !name.startsWith(prefix)) {
return {
errorMessage: `have "${prefix}" prefix`,
renameToName: prefix + name,
};
}
if (suffix && !name.endsWith(suffix)) {
return {
errorMessage: `have "${suffix}" suffix`,
renameToName: name + suffix,
};
}
const forbiddenPrefix = forbiddenPrefixes === null || forbiddenPrefixes === void 0 ? void 0 : forbiddenPrefixes.find(prefix => name.startsWith(prefix));
if (forbiddenPrefix) {
return {
errorMessage: `not have "${forbiddenPrefix}" prefix`,
renameToName: name.replace(new RegExp(`^${forbiddenPrefix}`), ''),
};
}
const forbiddenSuffix = forbiddenSuffixes === null || forbiddenSuffixes === void 0 ? void 0 : forbiddenSuffixes.find(suffix => name.endsWith(suffix));
if (forbiddenSuffix) {
return {
errorMessage: `not have "${forbiddenSuffix}" suffix`,
renameToName: name.replace(new RegExp(`${forbiddenSuffix}$`), ''),
};
}
// Style is optional
if (!style) {
return;
}
const caseRegex = StyleToRegex[style];
if (!caseRegex.test(name)) {
return {
errorMessage: `be in ${style} format`,
renameToName: convertCase(style, name),
};
}
}
};
const checkUnderscore = (isLeading) => (node) => {
const suggestedName = node.value.replace(isLeading ? /^_+/ : /_+$/, '');
report(node, `${isLeading ? 'Leading' : 'Trailing'} underscores are not allowed`, suggestedName);
};
const listeners = {};
if (!allowLeadingUnderscore) {
listeners['Name[value=/^_/]:matches([parent.kind!=Field], [parent.kind=Field][parent.alias])'] = checkUnderscore(true);
}
if (!allowTrailingUnderscore) {
listeners['Name[value=/_$/]:matches([parent.kind!=Field], [parent.kind=Field][parent.alias])'] = checkUnderscore(false);
}
const selectors = new Set([types && TYPES_KINDS, Object.keys(restOptions)].flat().filter(Boolean));
for (const selector of selectors) {
listeners[selector] = checkNode(selector);
}
return listeners;
},
};