Недавно мы в команде столкнулись с тем, что нам понадобилось кастомное правило для линтера. Немного поиска в гугле, и через час-полтора правило было готово. Делимся базовыми примерами, которые помогут вам погрузиться в процесс разработки правил.
Приступаем
Для написания правила, а точнее для исследования кода, нам понадобится абстрактное синтаксическое дерево. Быстрый способ получить его — воспользоваться 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-пакеты
Подробнее об абстрактных синтаксических деревьях
Надеемся, что кому-то из прочитавших хабровчан эта статья поможет написать своё первое правило для линтера.