В этой статье я покажу несколько способов создания собственного плагина для Eslint.

Первый способ создать правило — создать js-файл с правилом и экспортировать его.
Структура для обоих случаев:

Итак, внутри папки плагинов нам нужно создать индексный файл:

// index.js
const noDeprecatedMethod = require('./no-deprecated-method.js');

module.exports = {
  rules: {
    'no-deprecated-method': noDeprecatedMethod,
  },
};

Здесь мы будем импортировать правила, которые нам нужны и как они будут называться внутри eslintrc.

Простой пример пользовательского правила с заменой deprecatedMethod на newMethod будет выглядеть так:

module.exports = {
  meta: {
    messages: {
      avoidName: "Avoid using variables named '{{ name }}'", // Custom error message for the rule
    },
    type: 'problem', // The type of ESLint rule (problem, suggestion, etc.)
    fixable: 'code', // Indicates that this rule can automatically fix code issues
  },
  create(context) {
    return {
      Identifier(node) {
        if (node.name === 'deprecatedMethod') {
          context.report({
            node, // The AST node that triggered the error
            messageId: 'avoidName', // The identifier for the custom error message
            data: {
              name: 'deprecatedMethod', // Data to be used in the error message
            },
            fix(fixer) {
              // The fixer function for automatically fixing the reported error
              return fixer.replaceText(node, 'newMethod'); // Replaces 'deprecatedMethod' with 'newMethod'
            },
          });
        }
      },
    };
  },
};

Здесь мы определяем сообщения об ошибках, находим узлы по имени и заменяем их на newMethod.

И теперь нам нужно определить путь к нашему плагину в package.json.

 "devDependencies": {
     "eslint-plugin-no-deprecated": "file:plugins/no-deprecated",
  }

Также нам нужно добавить наш плагин в eslintrc:

{
  "root": true,
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "plugin:storybook/recommended"
  ],
  "parser": "@typescript-eslint/parser",
  "plugins": [
    "@typescript-eslint",
    "no-deprecated", // Adding our custom plugin
  ],
  "rules": {
    "no-deprecated/no-deprecated-method": 2, // Setting our custom rule type (as error in this case)
  },
  "env": {
    "browser": true,
    "node": true
  },
}

Не забывайте, что мы добавили путь в пакет json как «eslint-plugin-no-deprecated», но внутри плагинов мы отбрасываем часть «eslint-plugin» и указываем только «no-deprecated».

Вот как будет выглядеть ошибка:

Второй способ аналогичен предыдущему, но в комплекте с машинописным текстом.

Для начала мы можем создать проект vanilla ts с помощью:

yarn create vite

Удалите все, что находится внутри папки src, и измените пакет json.

{
  "name": "eslint-plugin-custom-rule",
  "version": "1.0.0",
  "main": "cjs/index.js", // Changing main js for bundled files
  "typings": "cjs/index.d.ts", // Changing typing for bundled files
  "private": true,
  "dependencies": {
    "@typescript-eslint/utils": "^6.1.0"
  },
  "devDependencies": {
    "@typescript-eslint/parser": "^6.1.0",
    "@typescript-eslint/rule-tester": "^6.1.0",
    "eslint": "^8.45.0",
    "typescript": "^5.1.6",
    "vitest": "^0.33.0"
  },
  "scripts": {
    "build": "yarn tsc -b", // Creatating build
    "test": "vitest"
  }
}

То же самое, что и в примере js, нам нужно создать индексный файл с правилами:

import { TSESLint } from '@typescript-eslint/utils';

import groupUpImportsByType from './group-up-imports-by-type';

export const rules = {
  'new-line-import-group': groupUpImportsByType,
} satisfies Record<string, TSESLint.RuleModule<string, Array<unknown>>>;

Для набора текста мы можем использовать пакет @typescript-eslint/utils.

Пользовательское правило для группировки импорта по типу будет выглядеть следующим образом:

import { TSESLint, TSESTree } from '@typescript-eslint/utils';

// Define custom message identifiers for ESLint messages
type MessageIds = 'messageIdNoLine';

// Define the ESLint rule and its properties
const groupUpImportsByType: TSESLint.RuleModule<MessageIds> = {
  defaultOptions: [], // Default options for the rule (none in this case)
  meta: {
    type: 'problem', // The type of ESLint rule (problem, suggestion, etc.)
    fixable: 'code', // Indicates that this rule can automatically fix code issues
    messages: {
      messageIdNoLine: 'No new line before multiline import', // Custom error message
    },
    schema: [], // Configuration schema for this rule (none in this case)
  },
  create(context: TSESLint.RuleContext<MessageIds, []>) {
    let lastGroupType = ''; // Initialize a variable to track the last import group

    return {
      ImportDeclaration(node: TSESTree.ImportDeclaration) {
        const importPath = node.source.value; // Get the path of the import statement

        let currentGroupType = ''; // Initialize a variable to track the current import group type

        // Determine the import group type based on the import path
        if (importPath.startsWith('.')) {
          currentGroupType = 'local folder files';
        } else if (importPath.startsWith('@')) {
          currentGroupType = 'internal modules';
        } else {
          currentGroupType = 'external modules';
        }

        const sourceCode = context.getSourceCode();
        const prevNodeToken = sourceCode.getTokenBefore(node); // Get the token before the current import statement

        // Check if there is a token before the current import statement
        if (!prevNodeToken) {
          return; // If there is no token before, exit the function
        }

        // Calculate the index of the previous token's location in the source code
        const prevNodeIndex = sourceCode.getIndexFromLoc(prevNodeToken.loc.start);

        // Get the node (AST node) corresponding to the previous token's location
        const prevNode = sourceCode.getNodeByRangeIndex(prevNodeIndex);

        // Check if the previous node is an 'ImportDeclaration' node
        const isPrevNodeImportType = prevNode?.type === 'ImportDeclaration';

        // If the previous node is not an 'ImportDeclaration', like 'Punctuator', etc..., exit the function
        if (!isPrevNodeImportType) {
          return;
        }

        // Calculate whether a newline is needed before the current import statement
        const isNewlineNeeded = node.loc.start.line - 1 === prevNode.loc.end.line;

        // Check if a new line is needed before the import statement and report an error if necessary
        if (lastGroupType !== '' && lastGroupType !== currentGroupType) {
          if (isNewlineNeeded) {
            context.report({
              node, // The AST node that triggered the error
              messageId: 'messageIdNoLine', // The custom message identifier ('messageIdNoLine' in this case)
              fix(fixer: TSESLint.RuleFixer) {
                // A function to provide a fix for the reported error
                return fixer.insertTextBefore(node, '\n'); // The fixer inserts a newline before the 'node'
              },
            });
          }
        }
        lastGroupType = currentGroupType; // Update the last import group type
      },
    };
  },
};

export default groupUpImportsByType;

Здесь мы получаем importPath и создаем тип, для которого добавим пустую строку перед группировкой импорта.

Прежде чем добавлять правило в пакет json, нам нужно запустить сборку пряжи, чтобы создать пакет.

Таким же образом добавим правило в пакет json и elsintrc:

{
  "name": "i18next-react-config",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --open",
    "build": "tsc && vite build",
    "lint": "eslint --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "test": "jest --coverage",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  },
  "dependencies": {
    ...
  },
  "devDependencies": {
    ...
    "eslint-plugin-no-deprecated": "file:plugins/no-deprecated",
    "eslint-plugin-group-imports": "file:plugins/group-imports"
  }
}
{
  "root": true,
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "plugin:storybook/recommended"
  ],
  "parser": "@typescript-eslint/parser",
  "plugins": [
    "@typescript-eslint",
    "no-deprecated",
    "group-imports"
  ],
  "rules": {
    "no-deprecated/no-deprecated-method": 2,
    "group-imports/new-line-import-group": 2,
  },
  "env": {
    "browser": true,
    "node": true
  }
}

Вот как будет выглядеть ошибка импорта до исправления:

После исправления eslint:

Также может быть задержка с WebStorm/IntelliJIdea, когда пользовательское правило не применяется, но работает с командой терминала. В этом случае мы можем вручную добавить пользовательское правило:

Подсказка: в большинстве случаев нам это не нужно, поможет перезагрузка WebStorm.

Документация для пользовательских правил eslint: https://eslint.org/docs/latest/extend/custom-rules
Фрагмент, в котором вы можете отладить свое пользовательское правило:
https://astexplorer.net /#/gist/f121a2a9edea666731e75aae1d013c9d/latest

Вот и все, надеюсь, вам было интересно.

Если у вас есть какие-либо предложения, вы можете добавить комментарий.

Исходный код: https://github.com/pryvalovbogdan/i18next-react-config/tree/add-custom-eslint-rule

Подписывайтесь, если вам интересны такие примеры.