January 12, 2023

Краткое введение в разработку собственных правил для 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-пакеты
Подробнее об абстрактных синтаксических деревьях