Краткое введение в разработку собственных правил для ESLint

от автора

Недавно мы в команде столкнулись с тем, что нам понадобилось кастомное правило для линтера. Немного поиска в гугле, и через час-полтора правило было готово. Делимся базовыми примерами, которые помогут вам погрузиться в процесс разработки правил.

Приступаем

Для написания правила, а точнее для исследования кода, нам понадобится абстрактное синтаксическое дерево. Быстрый способ получить его — воспользоваться AST Explorer.

Выбираем язык — JavaScript.
Парсер — babel-eslint9
Трансформер — ESLint v4 (можно и свежее, но для этой статьи достаточно и версии 4)

  • В левой верхней части вводим пример кода, который будем исследовать.

  • В правой верхней части вы увидите дерево вашего кода.

  • В нижней левой части будем писать код правила.

  • В правой нижней части отображается результат обработки вашего кода.

Задача

Придумаем какую-нибудь задачу для нас. Наше правило должно обрабатывать написанные нами функции следующим образом:

  • Если функция асинхронная, то ее название должно содержать Async в самом конце.

  • return должен быть отделен от основного кода блока одной пустой строкой.

Подопытный код

В левую верхнею секцию AST Explorer вставьте код функции:

async function helloFunction(names = []) {     const namesEdited = names         .map((name) => {             const formattedName = `hello ${name}`;             return formattedName;         });     return namesEdited; }

Вот такой результат мы хотим получить в итоге работы наших правил линтера:

async function helloFunctionAsync(names = []) {     const namesEdited = names         .map((name) => {             const formattedName = `hello ${name}`;              return formattedName;         });      return namesEdited; }

Первое правило

В верхней правой части отображается дерево c нодами нашего кода.

По дереву Program -> body видим ноду FunctionDeclaration — это и есть наша функция. В редакторе кода в нижней левой части напишем основную функцию нашего правила:

export default function(context) {     return {         FunctionDeclaration(node) {              },     }; };

Как видно из кода, функция принимает контекст и возвращает обработчик для блоков FunctionDeclaration, который в свою очередь принимает ноду в качестве аргумента.

Допишем первое условие для проверки функции на асинхронность и наличие Async в конце названия функции.

export default function(context) {     return {         FunctionDeclaration(node) {         const isAsyncFunction = node.async;           const hasAsyncAtEndOfName = /Async$/.test(node.id.name);            if (isAsyncFunction && !hasAsyncAtEndOfName) {             //             }         },     }; };

На рисунке выше видно, что отображается в дереве нашего кода. Теперь сообщим разработчику, что функция не соответствует правилу для асинхронных функций. Для этого воспользуемся методом `context.report`, который присутствует в объекте context.

export default function(context) {     return {         FunctionDeclaration(node) {         const isAsyncFunction = node.async;           const hasAsyncAtEndOfName = /Async$/.test(node.id.name);                  if (isAsyncFunction && !hasAsyncAtEndOfName) {             context.report({                 node,                   message: 'Названия асинхронных функций должны заканчиваться на Async',                 });             }         },     }; };

В правой нижней части AST Explorer мы увидим результат обработки кода нашим правилом. Также заметьте, что в блоке Fixed output follows нет варианта исправленного кода.

Нужно дописать еще одно свойство в объект передаваемый в метод report:

export default function(context) {     return {         FunctionDeclaration(node) {         const isAsyncFunction = node.async;           const hasAsyncAtEndOfName = /Async$/.test(node.id.name);            if (isAsyncFunction && !hasAsyncAtEndOfName) {             context.report({                     node,                     message: 'Названия асинхронных функций должны заканчиваться на Async',                     fix: function(fixer) {                          // Получаем токен названия функции                         const nameToken = context                                 .getTokens(node)                                 .find(token => token.value === node.id.name);                          // Возвращаем исправленное название                         return fixer.replaceText(nameToken, node.id.name + 'Async');                     }                 });             }         },     }; };

Результат обработки кода изменился, fixer смог изменить название функции на подходящее под правило.

Давайте допишем второе условие для нашего правила. Выделив слово «return» в исходном коде функции в левой верхней части astexplorer, мы увидим в дереве тип ноды ReturnStatement. Допишем перехватчик в нашу функцию:

export default function(context) {     return {         FunctionDeclaration(node) {             // ... тут код не меняем         },         ReturnStatement(node) {             // здесь опишем новый обработчик         },     }; };

Для обработки ReturnStatement нам надо:

  1. Убедиться, что return не является первым элементом в родительском блоке.

  2. Проверить, что перед строкой с return уже нет пустой строки.

  3. Учесть, что перед return могут быть комментарии.

Для этой функции нам понадобятся функции-помощники. Код снабдили комментариями. Код функций ниже взят из репозитория ESLint.

export default function(context) {     // Получение исходного кода     const sourceCode = context.getSourceCode();      // Проверка на возможность исправить найденное нарушение     function canFix(node) {         const leadingComments = sourceCode.getCommentsBefore(node);         const lastLeadingComment = leadingComments[leadingComments.length - 1];         const tokenBefore = sourceCode.getTokenBefore(node);          if (leadingComments.length === 0) {             return true;         }          if (lastLeadingComment.loc.end.line === tokenBefore.loc.end.line &&             lastLeadingComment.loc.end.line !== node.loc.start.line) {              return true;         }          return false;     }              // Получить номер строки токена предшествующего ноде           function getLineNumberOfTokenBefore(node) {       const tokenBefore = sourceCode.getTokenBefore(node);          if (tokenBefore) {         return tokenBefore.loc.end.line;         }          return 0;     }      // Подсчет строк с комментариемя перед нодой     function calcCommentLines(node, lineNumTokenBefore) {       const comments = sourceCode.getCommentsBefore(node);          let numLinesComments = 0;          if (!comments.length) {         return numLinesComments;         }          comments.forEach(comment => {         numLinesComments++;              if (comment.type === "Block") {         numLinesComments += comment.loc.end.line - comment.loc.start.line;             }              if (comment.loc.start.line === lineNumTokenBefore) {         numLinesComments--;             }              if (comment.loc.end.line === node.loc.start.line) {               numLinesComments--;             }         });          return numLinesComments;     }      // Проверка на наличие пустой строки перед нодой     function hasNewlineBefore(node) {       const lineNumNode = node.loc.start.line;         const lineNumTokenBefore = getLineNumberOfTokenBefore(node);         const commentLines = calcCommentLines(node, lineNumTokenBefore);          return (lineNumNode - lineNumTokenBefore - commentLines) > 1;     }      // Проверка токена перед нодой на совпадение с элементом массива     function isPrecededByTokens(node, testTokens) {       const tokenBefore = sourceCode.getTokenBefore(node);          return testTokens.includes(tokenBefore.value);     }      // Является ли нода первой в родительском блоке     function isFirstNode(node) {         // Тип родительской ноды       const parentType = node.parent.type;          /**         * Если родительская нода содержит body, то проверяем         * является ли переданная нода телом родительской или первым элементом в ней         */         if(node.parent.body) {         return Array.isArray(node.parent.body)           ? node.parent.body[0] === node           : node.parent.body === node;         }          /**         * Если родительская нода является Условием If,         * то надо проверить, что перед нашей нодой есть "else" или ")"         */         if (parentType === "IfStatement") {         return isPrecededByTokens(node, ['else', ')']);         }          /**         * Если родительская нода является блоком do-while,         * то надо проверить, что перед нашей нодой есть "do"         */         if (parentType === "DoWhileStatement") {             return isPrecededByTokens(node, ["do"]);         }         /**        * Если родительская нода является блоком switch case,        * то надо проверить, что перед нашей нодой есть ":"        */         if (parentType === "SwitchCase") {            return isPrecededByTokens(node, [":"]);         }          /**         * Во всех остальных случаях проверяем на ")" перед нашей нодой         */         return isPrecededByTokens(node, [")"]);     }      return {         FunctionDeclaration(node) {             // тут ничего не меняется         },              ReturnStatement(node) {             if (!isFirstNode(node) && !hasNewlineBefore(node)) {                 context.report({                     node,                     message: 'Поставьте пустую строку до return',                     fix: function(fixer) {                              if (canFix(node)) {                             const tokenBefore = sourceCode.getTokenBefore(node);                             const whitespaces = sourceCode.lines[node.loc.start.line - 1].replace(/return(.+)/, '');                             const newlines = node.loc.start.line === tokenBefore.loc.end.line ? `\n\n${whitespaces}` : `\n${whitespaces}`;                                  return fixer.insertTextBefore(node, newlines);                         }                              return null;                     }                 });             }         },     }; };

Результат выполнения наших правил виден в правой нижней части AST Explorer.

Теперь написанные правила нужно оформить в npm-пакет.

  1. Создаем директорию для пакета: `mkdir my-eslint-rules`

  2. Переходим в директорию проекта и инициализируем пакет: `cd my-eslint-rules && npm init —yes`

  3. Создаем файл index.js: `touch index.js`

Внутри index.js размещаем наши правила:

module.exports = {   rules: {     'async-func-name': {       create: function (context) {         return { /* тут код правила добавляющего Async для асинхронных функций */ }       },     },     'new-line-before-return': {         create: function (context) {             return { /* тут код правила для добавления пустых строк перед return */ }           },     },   } };

Можно разместить пакет в гит-репозитории или загрузить на npmjs.com. Для статьи мы будем проводить установку правила локально.

  1. Перейдите в директорию вашего проекта в котором вы хотите применить новые правила линтера: `cd my-project`.

  2. Установите новый пакет с кастомными правилами: `npm i ../my-eslint-rules —save-dev`.

  3. В файл конфигурации линтера `.eslintrc` добавьте наш плагин и определение правил:

{     "rules": {         "my-eslint-rules/async-func-name": "warn",         "my-eslint-rules/new-line-before-return": "warn"     },     "plugins": ["my-eslint-rules"] }

Предварительно в вашем проекте должен быть установлен ESLint.

Материалы по теме

Как работать с правилами. Материал от команды ESLint
Пакет вспомогательных утилит для написания правил
Как размещать npm-пакеты
Подробнее об абстрактных синтаксических деревьях

Надеемся, что кому-то из прочитавших хабровчан эта статья поможет написать своё первое правило для линтера.


ссылка на оригинал статьи https://habr.com/ru/company/sovcombank_technologies/blog/686662/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *