Всем привет! Меня зовут Анастасия Щедрина, я технический лидер по фронтенду проекта размещения объявлений в компании Домклик. Сегодня я расскажу вам немного о том, как устроены правила в ESLint, и покажу на примере, как можно разработать собственные.

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

Зачем нужны линтеры и как расширить стандартный набор правил

Сейчас сложно представить современный крупный проект без использования линтера. Он весьма полезен:

  • При хорошо настроенной конфигурации нет необходимости следить за форматированием кода при разработке функциональности.

  • Новому человеку проще влиться в команду, так как стиль кода строго регламентируется

  • Проще проходить рецензирование кода: больше не нужно комментировать и обсуждать с коллегами стиль кода в pull request-ах (отступы, переносы строк, опечатки).

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

ESLint — один из наиболее популярных линтеров для проектов на JavaScript, мощный инструмент для статического анализа кода. Он содержит большой набор встроенных правил, которые вы можете настроить для своего проекта.

Может быть так, что для качественной настройки проекта стандартных встроенных правил окажется недостаточно. Это не проблема, так как линтер позволяет легко расширять свои возможности с помощью плагинов, сообщество предлагает большое их количество. Плагины скачиваются как NPM-пакеты и подключаются к проекту через основную конфигурацию eslintrc.

Вы можете использовать готовые конфигурации, например, от Airbnb, либо глубже настроить правила, самостоятельно указав включение и отключение и параметры. Советую посмотреть доклад Дениса Красновского на HolyJS «ESLint — больше чем просто extend».

При работе над проектом вы можете заметить, что вам не хватает какого-то правила, которое нужно создать. Если он должно быть общим (неспецифичным для вашей команды или компании), то обязательно изучите существующие плагины; возможно, вы найдёте в них что-то подходящее и протестированное.

Как работает линтинг

Линтеры относятся к инструментам статического анализа кода, то есть без его реального выполнения. В их задачи входит:

  • анализ, парсинг строк исходного кода и построение на его основе AST (абстрактного синтаксического дерева);

  • запуск и выполнение правил над полученным AST;

  • отображение результатов анализа.

Немного теории

AST — это древовидное представление абстрактной синтаксической структуры исходного кода. Каждый узел дерева обозначает конструкцию, встречающуюся в исходном коде.

AST широко используются в компиляторах для представления структуры кода, могут применяться для проверки кода, оптимизации и преобразования в другой язык.

Чтобы перевести ваш код в AST, необходимо прогнать его через парсер. Дерево программы на Javascript можно представить в виде объекта, состоящего из ключей и значений, которые представляют собой части нашего исходного кода:

Дерево начинается с верхнего уровня нашего кода и далее ветвится на переменные, функции и выражения, которые мы используем. Каждая такая часть дерева называется узлом. Далее мы ещё вернёмся к деревьям и рассмотрим их как результат парсинга нашего кода.

Парсеры

Основной задачей парсеров как раз и является преобразование нашего исходного кода в структуру AST для её дальнейшего анализа.

ESLint поставляется со встроенным парсером языка Javascript — Espree. Но вы можете использовать свой, чтобы анализировать линтером код на других языках или расширяя возможности встроенного парсера. Например, ESLint может линтить код на Typescript, если подключить к нему @typescript-eslint/parser. В песочнице typescript-lint можно сравнить анализ кода с помощью Espree и Typescript-парсера.

С ESLint совместимы такие инструменты:

Правила

Правила — основа архитектуры линтера. Они содержат набор инструкций, которые необходимо применить к полученному после парсинга AST. Правила получают на вход контекст с некоторой дополнительной информацией и API для работы с исходным кодом, узлами и токенами, и отправки результата. Основные задачи правил:

  • проинспектировать AST на соответствие определённым шаблонам;

  • сообщить об ошибках, если эти соответствия были найдены.

Анатомия правил ESLint

Все современные правила (и собственные, и базовые правила линтера) имеют одинаковую структуру. Необходимо определить метаинформацию правила, а также метод create. Базовый формат правил ESLint выглядит так:

// custom-rule.js
module.exports = {
  meta: {...},
  create(context) {
    return {...};
  }
};

Этот формат актуален для ESLint версии ≥ 3.0.0. Вы также можете встретить устаревший, подходящий для предыдущих версий линтера.

Рассмотрим некоторые основные свойства, которые могут содержаться в объекте meta:

  • meta: (object) — содержит метаинформацию о правиле.

    • type: (string) — тип правила, может быть "problem""suggestion" или "layout".

    • docs: (object) — краткая документация по правилу (обязательное поле для базового правила и опциональное для собственных).

    • fixable: (string) — означает, что правило исправляет найденную проблему при запуске с флагом --fix. Может принимать значения "code" или "whitespace".

    • schema: (object | array) — схема параметров, чтобы ESLint мог проверять указанные в конфигурации значения.

Для примера разберём несложное встроенное в ESLint правило no-debugger. Его метаданные:

module.exports = {
    meta: {
      type: "problem",
      docs: {
        description: "Disallow the use of `debugger`",
        recommended: true,
        url: "https://eslint.org/docs/latest/rules/no-debugger";
      },
  
      fixable: null,
      schema: [],
  
      messages: {
        unexpected: "Unexpected 'debugger' statement";
      }
  },

  create(context) {...}

};

Выглядит достаточно просто. Правило не принимает никаких дополнительных параметров, поэтому свойство schema пустое. Также здесь сразу указано сообщение, которое будет выдаваться при ошибке (в коде на него можно ссылаться по идентификатору).

Рассмотрим основную функцию нашего правила — create(). Она должна вернуть объект с обратными вызовами. Эти вызовы будет совершаться при обходе AST нашего исходного кода. Возвращаемый объект в качестве ключа может содержать такие значения:

  • Селектор или тип узла (обратный вызов совершится при проходе вниз по дереву и соблюдении условия селектора).

  • Селектор или тип узла и постфикс :exit (обратный вызов совершится при проходе обратно вверх по дереву).

  • Название события (code path). Подробнее об этом типе можно почитать в документации.

Селектор — это строка, которую можно использовать для сопоставления узлов в абстрактном синтаксическом дереве (AST). Синтаксис селекторов в ESLint похож на CSS. Можно указать вложенность или выбрать только некоторые подходящие узлы. Пример из документации:

module.exports = {
  create(context) {
    return {
      "IfStatement > BlockStatement": function(blockStatementNode) {...},
      "FunctionDeclaration[params.length>3]": function(functionDeclarationNode) {...}
    };
  }
};

Каждый обратный вызов, соответствующий определённому селектору, принимает в качестве аргумента текущий обрабатываемый узел AST, который подходит под этот селектор.

Функция create принимает один важный аргумент — контекст. Объект  context содержит в себе информацию, относящуюся к контексту выполнения правила.

Основное свойство контекста, без которого нельзя представить ни одно правило — метод report(descriptor), позволяющий правилу сообщить о найденной в коде проблеме. В нашем примере с правилом no-debugger функция create выглядит так:

module.exports = {
    meta: {...},
    create(context) {
      
      return {
        DebuggerStatement(node) {
          context.report({
            node,
            messageId: "unexpected";
          });
        }
      };

    }
};

Полный исходный код правила no-debugger есть в репозитории ESLint.

В этом правиле селектор DebuggerStatement соответствует AST-узлам данного типа. Обратный вызов правила будет совершаться только для узлов DebuggerStatement. Тело обратного вызова не содержит ничего, кроме сообщения о найденной ошибке, так как вся логика поиска проблемного кода реализована с помощью правильно подобранного селектора.

Благодаря переданному в context.report() узлу в консоли при запуске ESLint с текущим правилом или в нашей IDE мы также сможем увидеть конкретный код, для которого правило было нарушено.

Практика. Парсим код и пишем своё правило

Создадим своё правило, позволяющее решить следующую задачу для React-приложений:

  • Каждое описание типа prop содержит дополнительный комментарий.

  • Если prop один, то отступа в виде новой строки перед комментарием нет.

  • Если prop-ов несколько, то перед каждым из них находится пустая строка.

Немного пояснения: в React-приложениях, не использующих TypeScript, можно описывать типы prop-ов (свойств компонента) с помощью библиотеки prop-types. Каждый параметр компонента может соответствовать PropTypes.string или PropTypes.number, и так далее.

В качестве ограничений примем, что первая версия правила будет работать только для функциональных компонентов и не станет обрабатывать вложенные описания свойств объектов.

Примечание. Вы можете считать, что в таком правиле нет необходимости, так как prop-ы должны иметь понятное и однозначное название и без необходимых комментариев, или потому, что сейчас проекты в основном используют Typescript. Давайте рассмотрим это правило в качестве учебного примера.

Шаг 1. Опишем тестовые сценарии

Начнём работу над правилом с описания сценариев, когда это правило должно сообщить об ошибке, а в каких случаях код выглядит корректно. Примеры некорректного кода:

ExampleComponent.propTypes = {
  
  /** Дополнительный класс обёртки */
  wrapperClassName: PropTypes.string
};
UserWallet.propTypes = {
  balance: PropTypes.number.isRequired,
  userId: PropTypes.number.isRequired,
  e2eId: PropTypes.string.isRequired 
};
UserWallet.propTypes = {
  /** Баланс кошелька */
  balance: PropTypes.number.isRequired,
  /** ID пользователя, к которому привязан кошелек */
  userId: PropTypes.number.isRequired,
  /** Флаг, идентификатор для E2E тестирования */
  e2eId: PropTypes.string.isRequired 
};
UserWallet.propTypes = {
  /** Баланс кошелька */
  balance: PropTypes.number.isRequired,


  /** ID пользователя, к которому привязан кошелек */
  userId: PropTypes.number.isRequired,

  /** Флаг, идентификатор для E2E тестирования */
  e2eId: PropTypes.string.isRequired 
};

Примеры корректного кода:

ExampleComponent.propTypes = {
  /** Дополнительный класс обёртки */
  wrapperClassName: PropTypes.string
};
UserWallet.propTypes = {

  /** Баланс кошелька */
  balance: PropTypes.number.isRequired,

  /** ID пользователя, к которому привязан кошелек */
  userId: PropTypes.number.isRequired,

  /** Флаг, идентификатор для E2E тестирования */
  e2eId: PropTypes.string.isRequired 
};

Шаг 2. Настроим среду для разработки правила

Для анализа кода нам необходимо сформировать AST. Дерево предоставляет информацию об узлах кода, а они содержат определённые свойства, такие как type. Анализировать будем с помощью сервиса AST Explorer.

Для практики я предлагаю вам перейти на сайт AST Explorer и параллельно выполнять шаги по написанию правила и анализу исходного кода.

По умолчанию интерфейс сервиса выглядит так:

Инструмент достаточно удобен для написания ESLint-правил, предоставляет всю необходимую функциональность: парсинг исходного кода в AST, запуск линтера с применением парсера к введённому коду и вывод результата выполнения правила с исправленным вариантом кода.

Настроим сервис. Нажмите на пункт меню Transform и выберите Eslint V8 . Интерфейс разделится на четыре экрана:

Левый верхний — ваш исходный код для анализа.

Правый верхний — результат парсинга в виде AST или его JSON-представления.

Левый нижний — наш код ESLint-правила.

Правый нижний — результат линтинга.

Продолжим настройку, выбрав парсер для нашего кода. Я предлагаю использовать Espree, так как он установлен в ESLint как парсер по умолчанию.

Обратите внимание, что от выбора парсера зависит формат итогового AST и, соответственно, код нашего правила, который это дерево использует.

После выбора парсера левый нижний экран заполнился примером правила в формате, который мы уже обсуждали (содержит meta и create).

Шаг 3. Рассмотрим AST подробнее

Давайте заменим исходный код в левом верхнем экране AST Explorer на простой компонент React, который принимает один prop и возвращает простой JSX с текстом.

import PropTypes from 'prop-types';


const MyComponent = ({ text }) => {
  return <div>{ text }</div>;
};


MyComponent.propTypes = {
  
  /** Текст для отображения */
  text: PropTypes.string.isRequired
};

export default MyComponent;

В правом верхнем экране мы получаем соответствующее добавленному коду дерево:

Код имеет такую структуру:

Корень нашего дерева — Program. Этот узел содержит:

  • ImportDeclaration;

  • VariableDeclaration;

  • ExpressionStatement;

  • ExportDefaultDeclaration.

Это соответствует нашему исходному коду. Обратите внимание, что парсер Espree также позволяет нам определять узлы для React, такие как JSXElement, JSXOpeningElement и JSXClosingElement. Они содержатся как узлы в глубине части дерева VariableDeclaration.

Также мы можем посмотреть структуру AST в формате JSON (часть кода опущена):

{
  "type": "Program",
  "start": 0,
  "end": 237,
  "range": [
    0,
    236
  ],
  "body": [
    {
      "type": "ImportDeclaration",
      "start": 0,
      "end": 35,
      "range": [
        0,
        35
      ],
      "specifiers": [...],
      "source": {...}
    },
    {
      "type": "VariableDeclaration",
      "start": 38,
      "end": 106,
      "range": [
        38,
        106
      ],
      "declarations": [...],
      "kind": "const"
    },
    {
      "type": "ExpressionStatement",
      "start": 109,
      "end": 207,
      "range": [
        109,
        207
      ],
      "expression": {...}
    },
    {
      "type": "ExportDefaultDeclaration",
      "start": 209,
      "end": 236,
      "range": [
        209,
        236
      ],
      "declaration": {...}
    }
  ],
  "sourceType": "module"
}

Свойства узлов могут отличаться друг от друга и зависят от типа. Список возможных значений можно посмотреть в спецификации ESTree.

Шаг 4. Выбор селектора

Для правила нас интересует узел ExpressionStatement, которые соответствует исходному коду с определением propTypes. Попробуем проверить работу нашего селектора и выведем в консоль полученную ноду:

export function create(context) {
  return {
    ExpressionStatement(node) {
      console.log('selected node', node);
    }
  };
};

ASTExplorer применяет наше правило к исходному коду и в правом нижнем углу отображается результат запуска. В консоли мы увидим вывод узла на входе нашего обратного вызова.

В спецификации ESTree на Github можно посмотреть описание этого типа, оно содержит свойство expression. Узлы типа ExpressionStatement могут быть разными и не всегда наш селектор будет срабатывать на нужные нам выражения.

Чем точнее будет выбран селектор, тем проще будет обработать наше правило и тем эффективнее оно будет работать.

Так как нас интересуют выражения с присвоением объекта свойству propTypes, мы можем рассмотреть AssignmentExpression в качестве селектора. Этот тип узла мы можем видеть в значении свойства expression нашего выражения (часть свойств опущена):

{
  "type": "ExpressionStatement",...,
  "expression": {
    "type": "AssignmentExpression",
    "operator": "=",
    "left": {
      "type": "MemberExpression",...,
      "object": {
        "type": "Identifier",...,
        "name": "MyComponent"
      },
      "property": {
        "type": "Identifier",...,
        "name": "propTypes"
      }
    },
    "right": {
      "type": "ObjectExpression",...,
      "properties": [
        {
          "type": "Property",
        }
      ]
    }
  }
}

Возможно, селектор можно улучшить, но мы оставим это за рамками статьи.

Таким образом, мы получим подходящий нам узел и можем проверить, что не будем обрабатывать другие присвоения уже с помощью условия в теле нашей функции:

export function create(context) {
  return {
    AssignmentExpression(node) {
      const isPropTypesExpression = node.left.property
        && node.left.property.type === "Identifier"
        && node.left.property.name === "propTypes";
      
      if (!isPropTypesExpression) {
        return;
      }

      // TODO: логика правила
    }
  };
};

Проверим, что наше условие отрабатывает верно и что оно ложно для узла с присвоением другого свойства, например, defaultProps:

Шаг 5. Напишем основную логику правила

Алгоритм правила форматирования отступов propTypes можно описать так:

  1. Получаем все prop-ы в нашем описании типов (массив элементов в объекте propTypes).

  2. Пробегаемся по ним:

    1. Если у prop-а нет комментария, то сообщаем об ошибке.

    2. Если prop один, то проверяем, что перед комментарием нет лишних строк (строка начала описания prop-ов соответствует строке комментария). Иначе сообщаем об ошибке.

    3. Если prop-ов несколько, то проверяем, что перед каждым комментарием ровно одна строка. Иначе сообщаем об ошибке.

Начнём пошагово добавлять нашу реализацию с первого пункта. Нам необходимо получить массив prop-ов, пройтись по ним и проверить наличие комментария перед каждым из них:

const sourceCode = context.getSourceCode();
const propTypes = node.right.properties;

propTypes.forEach((currentProp) => {
  const leadingComments = sourceCode.getCommentsBefore(currentProp);

  if (!leadingComments || leadingComments.length !== 1) {
    // TODO сообщение об ошибке

    return;
  }
});

С помощью context получим наш исходный код и затем обратимся к методу getCommentsBefore(), чтобы получить список всех комментариев перед текущим prop-ом. Описания этих и других доступных методов обращения к комментариям, токенам и т. п. можно изучить в документации.

Добавим сообщение об ошибке, используя стандартный метод context.report(): укажем в качестве узла ошибки текущий prop и опишем наше сообщение:

if (!leadingComments || leadingComments.length !== 1) {
  context.report({
    node: currentProp,
    message: "Every propType shoud have one comment before"
  });

  return;
}

Перейдём к следующему шагу и реализуем проверку для одного описания prop-а. Для сравнения строк используем свойства loc.start.line начального узла и узла текущего комментария:

export function create(context) {
  return {
    AssignmentExpression(node) {
      const isPropTypesExpression = node.left.property
        && node.left.property.type === "Identifier"
        && node.left.property.name === "propTypes";
      
      if (!isPropTypesExpression) {
        return;
      }
      
      const sourceCode = context.getSourceCode();
      const propTypes = node.right.properties;
      const propTypesFirstLine = node.loc.start.line + 1;
      
      let propStartLine = propTypesFirstLine;
      
      propTypes.forEach((currentProp) => {
        const leadingComments = sourceCode.getCommentsBefore(currentProp);

        if (!leadingComments || leadingComments.length !== 1) {
          // ...
          return;
        }

      	const hasOneProp = propTypes.length === 1;
        const currentComment = leadingComments[0];
        const currentCommentStartLine = currentComment.loc.start.line;

        if (hasOneProp) {
          if (propTypesFirstLine === currentCommentStartLine) {
            return;
          } else {
            context.report({
              node: currentComment,
              message: "If propTypes has one prop it should not have empty line before comment"
            });
          }
        }

        propStartLine = currentProp.loc.end.line + 1;
      });
    }
  };
};

Для третьей проверки достаточно просто сравнить строки комментария и начала prop-а:

if (hasOneProp) {...}
else {
  if (currentCommentStartLine !== propStartLine + 1) {
  context.report({
    node: currentComment,
    message: "If propTypes has several props it should have one empty line before each comment"
  });
}

propStartLine = currentProp.loc.end.line + 1;

Теперь мы можем проверить код на наших тестовых значениях, добавив все их в исходный код:

На текущих сценариях всё отработало корректно. Мы получили сообщения об ошибке с указанием строки, где была найдена проблема.

При необходимости можно реализовать автоматическое исправление найденных проблем, добавив объект сообщения о проблеме в реализацию метода fix. Метод принимает аргумент fixer, и можно использовать стандартные методы для изменения исходного кода (реализация приведена для примера):

context.report({
  node: currentComment,
  message: "If propTypes has several props it should have one empty line before each comment",
  fix(fixer) {
    const prevTokenForComment = sourceCode.getTokenBefore(currentComment);

    if (currentCommentStartLine < propStartLine + 1) {
      return fixer.insertTextAfter(prevTokenForComment, "\n");
    }

    const beforeCommentRange = [prevTokenForComment.end, currentComment.start];

    return fixer.replaceTextRange(beforeCommentRange, "\n");
});

Шаг 6. Подумайте о граничных случаях и протестируйте правило

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

При написании правила в отдельном пакете можно протестировать его функциональность с помощью модульного тестирования. Для правил в рамках ESLint для этого используется RuleTester.

После окончания разработки обязательно протестируйте правило на реальном, желательно крупном проекте, чтобы проверить правило в действии. Для этого можно локально создать пакет с помощью утилиты Yeoman или написать простой пакет в директории рядом с проектом с помощью следующих команд (указав свои названия плагинов):

mkdir eslint-plugin-prop-types-comments
cd eslint-plugin-prop-types-comments
npm init --yes

Обычно для плагинов ESLint используется префикс eslint-plugin-.

В корне новой директории необходимо создать файл index.js и добавить в него реализованное правило, экспортировав meta и create.

module.exports = {
  rules: {
    "indents": {
      meta: {
        type: "layout",
        ...
      },
      create: function (context) {
        ...
      }
	}
  }
};

Далее своё правило можно подключить, установив его в проекте следующим образом:

npm i ../eslint-plugin-prop-types-comments --save-dev

И настроить его подключение в конфигурации ESLint в проекте (добавив к остальным правилам и плагинам, если они есть):

{
  "plugins": ["prop-types-comments"],
  "rules": {
    "prop-types-comments/indents": "error"
  }
}

Шаг 7. Проверьте производительность

Сложное или неоптимальное правило в крупном проекте может сильно увеличить длительность линтинга исходного кода. Убедитесь, что, получив пользу от нового правила, вы не увеличиваете длительность прогона слишком сильно. Проверить можно добавлением к команде eslint префикса TIMING=1:

TIMING=1 eslint

Заключение

ESLint — мощный инструмент для анализа вашего кода. Я постаралась разобрать, как работает линтер и как устроены его правила, а также рассмотрела этапы создания своих правил на примере проверки отступов для описания prop-ов React-приложения. Вы можете использовать этот пример как отправную точку для создания своих собственных правил в ESLint.

Результат создания правила опубликован на Github: eslint-plugin-prop-types-comments

Комментарии (0)