Краткое введение в разработку собственных правил для 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 нам надо:
- Убедиться, что return не является первым элементом в родительском блоке.
- Проверить, что перед строкой с return уже нет пустой строки.
- Учесть, что перед 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-пакет.
- Создаем директорию для пакета: `mkdir my-eslint-rules`
- Переходим в директорию проекта и инициализируем пакет: `cd my-eslint-rules && npm init --yes`
- Создаем файл 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. Для статьи мы будем проводить установку правила локально.
- Перейдите в директорию вашего проекта в котором вы хотите применить новые правила линтера: `cd my-project`.
- Установите новый пакет с кастомными правилами: `npm i ../my-eslint-rules --save-dev`.
- В файл конфигурации линтера `.eslintrc` добавьте наш плагин и определение правил:
{ "rules": { "my-eslint-rules/async-func-name": "warn", "my-eslint-rules/new-line-before-return": "warn" }, "plugins": ["my-eslint-rules"] }
Предварительно в вашем проекте должен быть установлен ESLint.
Материалы по теме
Как работать с правилами. Материал от команды ESLint
Пакет вспомогательных утилит для написания правил
Как размещать npm-пакеты
Подробнее об абстрактных синтаксических деревьях