В этом сообщении блога мы узнаем, как написать собственный плагин ESLint, чтобы помочь вам с ручными задачами, которые в противном случае заняли бы у вас несколько дней.

Давайте погрузимся в мир AST: они не так страшны, как кажутся!

Зачем писать собственные плагины и правила для eslint?

  • Писать интересно и помогает узнать больше о JS/TS.
  • Это может помочь внедрить стили и шаблоны, характерные для компании.
  • Это может сэкономить вам дни ручной работы 😃

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

Дело в том, что правила lint практически безграничны. На самом деле, мы регулярно видим появление новых плагинов для определенных библиотек, фреймворков или вариантов использования. Так почему бы не написать свой? Обещаю, это не так страшно!

Воображаемая (не очень) проблема, которую мы решаем

В учебниках часто используются foo, bar и baz или подобные абстрактные понятия, чтобы научить вас чему-то. Почему бы вместо этого не решить настоящую проблему? Проблема, с которой мы столкнулись в команде, пытаясь исправить некоторые ошибки типов TypeScript после преобразования в TypeScript.

Если вы когда-либо использовали энзим для тестирования кодовой базы TypeScript React, вы, вероятно, знаете, что shallow вызовы принимают общий, ваш компонент. например shallow<User>(<User {...props}) .

А если не пройти? Это может выглядеть «хорошо», но как только вы попытаетесь получить доступ к свойствам или методам компонента, у вас возникнут ошибки типа, потому что TypeScript считает, что ваш компонент является универсальным компонентом реакции, без свойств, состояния или методов.

Конечно, если вы пишете новые тесты с нуля, вы сразу поймаете их с помощью команды tsc IDE или TypeScript и добавите общий. Но вам может понадобиться добавить его, например, в 1, 100 или даже 1000 тестов, потому что:

  • Вы перенесли целый проект с JS на TS, вообще без типизации
  • Вы перенесли весь проект из потока в TS с отсутствующими типизациями для некоторых библиотек
  • Вы новый участник проекта TS, используете enzyme для тестирования компонентов реакции и не знакомы с дженериками.

На самом деле, это проблема, с которой я столкнулся в команде, и то же самое правило eslint, которое мы напишем сегодня, сэкономило нам много времени, исправив это во всем нашем проекте.

Что вообще такое АСТ?

Прежде чем мы начнем копаться в создании правил ESLint, нам нужно понять, что такое AST и почему они так полезны нам как разработчикам.

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

Мы пишем код для компьютеров на высокоуровневых, понятных человеку языках, таких как C, Java, JavaScript, Elixir, Python, Rust… но компьютер — не человек: другими словами, он не может знать значение того, что мы написать. Нам нужен способ для компьютера анализировать ваш код, чтобы понять, что const — это объявление переменной, {} иногда отмечает начало выражения объекта, иногда функции… и т. д.

Как только он это поймет, мы сможем сделать с ним много вещей: выполнить его, передав его движку, проанализировать его... или даже сгенерировать новый код, делая то же самое. обрабатывать наоборот.

AST являются основой инструментов, которые мы используем ежедневно, таких как Babel, Webpack и eslint/prettier.

Цитируя Джейсона Уильямса, автора движка boa JS на Rust, базовой архитектурой для создания AST может быть:

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

Такое дерево звучит знакомо? Это очень похоже на то, как ваш HTML-код будет преобразован в дерево узлов DOM. На самом деле, мы можем генерировать абстрактные представления любого языка, если для него есть синтаксический анализатор.

Возьмем простой пример JS:

const user = {
  id: 'unique-id-1',
  name: 'Alex',
}

Это можно представить так с помощью AST:

Для визуализации мы используем один отличный инструмент: https://astexplorer.net. Это позволяет нам визуализировать синтаксические деревья для многих языков. Я рекомендую вставить туда разные фрагменты кода JS и TS и немного изучить инструмент, так как мы будем использовать его позже!

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

Создание проекта TS для lint

Если у вас уже есть проект TS + React + Jest, вы можете перейти к следующему разделу или выбрать то, что вам нужно, из этого!

Давайте создадим фиктивный проект React + TypeScript + Jest + Enzyme, который будет страдать от проблемы с типизацией, которую мы видели ранее.

Концептуально разбор кода TypeScript ничем не отличается от кода JS, нам нужен способ разобрать код TS в дерево. К счастью, плагин typescript-eslint уже поставляется с собственным парсером TS. Итак, начнем!

Создайтеast-learningпапку и добавьте package.json файл, содержащий определения react, jest, фермента, eslint и всех типов.

{
  "name": "ast-learning",
  "version": "1.0.0",
  "description": "Learn ASTs by writing your first ESLint plugin",
  "main": "src/index.js",
  "dependencies": {
    "react": "17.0.0",
    "react-dom": "17.0.0",
    "react-scripts": "3.4.3"
  },
  "devDependencies": {
    "@babel/preset-env": "^7.12.1",
    "@babel/preset-react": "^7.12.5",
    "@types/enzyme": "^3.10.8",
    "@types/enzyme-adapter-react-16": "^1.0.6",
    "@types/jest": "^26.0.15",
    "@types/react": "^16.9.56",
    "@types/react-dom": "^16.9.9",
    "@typescript-eslint/eslint-plugin": "^4.8.1",
    "@typescript-eslint/parser": "^4.8.1",
    "babel-jest": "^26.6.3",
    "enzyme": "3.11.0",
    "enzyme-adapter-react-16": "1.15.5",
    "eslint": "^7.13.0",
    "jest": "^26.6.3",    
    "react-test-renderer": "^17.0.1",
    "ts-jest": "^26.4.4",
    "typescript": "3.8.3"
  },
  "scripts": {
    "lint": "eslint ./*.tsx",
    "test": "jest index.test.tsx",
    "tsc": "tsc index.tsx index.test.tsx --noEmit true --jsx react"
  }
}

Давайте также создадим минимальный файл tsconfig.jso, чтобы порадовать компилятор TypeScript (он же tsc) :).

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "module": "esnext",
    "lib": ["es6", "dom"],
    "jsx": "react",
    "moduleResolution": "node"
  },
  "exclude": [
    "node_modules"
  ]
}

В качестве последнего шага настройки в наш проект давайте пока добавим .eslintrc.js с пустыми правилами:

export default {
 "parser": "@typescript-eslint/parser",
 "parserOptions": {
  "ecmaVersion": 12,
  "sourceType": "module"
 },
 "plugins": [
  "@typescript-eslint",
  "ast-learning",
 ],
 "rules": {
 }
}

Теперь, когда у нашего проекта есть готовые настройки, давайте создадим index.tsx, содержащий компонент Пользователь:

import * as React from "react";
type Props = {};
type State = { active: boolean };
class User extends React.Component<Props, State> {
 constructor(props: Props) {
   super(props);
    this.state = { active: false };
  }
   toggleIsActive() {
    const { active } = this.state;
    this.setState({ active: !active });
  }
render() {
    const { active } = this.state;
    return (
      <div className="user" onClick={() => this.toggleIsActive()}>
        User is {active ? "active" : "inactive"}
      </div>
    );
  }
}
export {User}

И тестовый файл под названием index.test.tsx:

import * as React from 'react'
import * as Adapter from "enzyme-adapter-react-16";
import * as enzyme from "enzyme";
import {User} from './index'
const {configure, shallow} = enzyme
configure({ adapter: new Adapter() });
describe("User component", () => {
  it("should change state field on toggleIsActive call", () => {
    const wrapper = shallow(<User />);
 // @ts-ignore
    wrapper.instance().toggleIsActive();
 // @ts-ignore
    expect(wrapper.instance().state.active).toEqual(true);
  });
it("should change state field on div click", () => {
    const wrapper = shallow(<User />);
    wrapper.find(".user").simulate("click");
 // @ts-ignore
    expect(wrapper.instance().state.active).toEqual(true);
  });
});

Теперь запустите npm i && npx ts-jest config:init && npm run test.

Мы видим, что TSX отлично компилируется из-за комментариев к директиве // @ts-ignore.

Комментарии @ts-ignoredirective предписывают компилятору TypeScript игнорировать ошибки типа в следующей строке.

Итак, он компилируется и тесты проходят нормально, все хорошо, верно?

Не совсем! Давайте удалим комментарии @ts-ignoredirective и посмотрим, что произойдет.

Теперь тесты даже не запускаются, и у нас есть 3 ошибки в наших тестах.

О нет 😞! Как видно из вступления, мы могли исправить это, вручную добавив общий код ко всем нашим неглубоким вызовам. Может, но, вероятно, не должен.

const wrapper = shallow<User>(<User />); // here, added User generic type
  • Шаблон здесь очень прост, нам нужно получить аргумент, с которым вызывается shallow, а затем передать его как аргумент типа (также известный как общий). Конечно, мы можем заставить компьютер сгенерировать это для нас? Если есть шаблон, есть автоматизация.

Ура, это наш вариант использования правила lint! Давайте напишем код, который исправит наш код за нас 🤯

Если есть шаблон, есть автоматизация

Если вы можете найти шаблоны в своем коде, которые ваш компьютер может использовать для анализа, предупреждения и/или блокирования определенных действий или даже написания кода для вас, с AST можно творить чудеса. В таких случаях вы можете:

  • Напишите правило ESLint,либо:
    — с автоисправлением, чтобы предотвратить ошибки и помочь с соглашениями, с автоматически сгенерированным кодом< br /> - без автофикса, чтобы подсказать разработчику, что ему делать
  • Напишите codemod. Другая концепция, также достигнутая благодаря AST, но созданная для работы с большими пакетами файлов и с еще большим контролем над прохождением AST и манипулированием ими. Запуск их в вашей кодовой базе — более тяжелая операция, которую нельзя запускать при каждом нажатии клавиши, как в случае с eslint.

Как вы уже догадались, мы напишем правило/плагин eslint. Давайте начнем!

Инициализация нашего проекта плагина eslint

Теперь, когда у нас есть проект, для которого нужно написать правило, давайте инициализируем наш плагин eslint, создав другую папку проекта с именем eslint-plugin-ast-learning рядом с ast-learning.

⚠️ Плагины eslint следуют соглашению eslint-plugin-your-plugin-name!

Начнем с создания файла package.jsonfile:

{
  "name": "eslint-plugin-ast-learning",
  "description": "Our first ESLint plugin",
  "version": "1.0.0",
  "main": "index.js"
}

И index.js, содержащий все правила нашего плагина, в нашем случае только одно, require-enzyme-generic:

const rules = {
 'require-enzyme-generic': {
  meta: {
   fixable: 'code',
   type: 'problem',
   },
  create: function (context) {
   return {
   }
  },
 },
}
module.exports = {
 rules,
}

Каждое правило содержит два свойства: meta и create. Вы можете прочитать больше в документах по правилам ESLint, но суть в том, что

объект meta будет содержать всю информацию о вашем правиле, которое будет использоваться eslint, например:

  • В двух словах, что он делает?
  • Это автофиксируется?
  • Вызывает ли это ошибки и имеет высокий приоритет для решения, или это просто стилистический аспект?
  • Какая ссылка на полные документы?

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

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

  • CallExpression: выражение вызова функции, например:
 shallow()
  • VariableDeclaration: объявление переменной (без предыдущего ключевого слова var/let/const)
SomeComponent = () => (<div>Hey there</div>)
  • StringLiteral: строковый литерал, например 'test'

Лучший способ понять, что к чему, — вставить свой код в ASTExplorer (убедившись, что вы выбрали правильный синтаксический анализатор для вашего языка) и изучить различные токены.

Определение критериев появления ошибки lint

Перейдите на левую панель проводника AST и выберите наш вызов shallow() (или наведите курсор на соответствующее свойство на правой панели): вы увидите, что он имеет тип CallExpression

Итак, давайте добавим theCallExpressionproperty к объекту, возвращаемому нашим методом create:

create: function (context) {
   return {
    CallExpression (node) {
     // TODO: Magic 🎉
    }
   }
}

Каждый метод, который вы объявите, будет вызываться ESLint с соответствующим узлом при обнаружении.Если мы посмотрим на документацию babel (формат AST, который использует синтаксический анализатор TS), мы видим, что узел для CallExpressionсодержит свойство callee, которое является Expression. Выражение имеет свойство name, поэтому давайте создадим проверку внутри нашего метода CallExpression

CallExpression (node) {
  if ((node.callee.name === 'shallow')) // run lint logic on shallow calls
}

Мы также хотим убедиться, что мы нацелены только на неглубокие вызовы без уже существующих общих. Вернувшись к AST Explorer, мы видим, что есть запись с именем typeArguments, которую Babel AST вызывает typeParameters, которая представляет собой массив, содержащий аргумент(ы) типа нашего вызова функции. Итак, давайте удостоверимся, что он не определен (нет универсального, например, shallow() или пустой универсальный, например, shallow<>) или является пустым массивом (это означает, что у нас есть универсальный элемент, внутри которого ничего нет).

if (
      node.callee.name === 'shallow' &&
      !node.typeParameters
)

Вот так! Мы нашли условие, при котором мы должны сообщить об ошибке.

Следующим шагом будет использование метода context.report. Глядя на документы ESLint, мы видим, что этот метод используется для сообщения о предупреждении/ошибке, а также для предоставления метода автоисправления:

Основным методом, который вы будете использовать, является context.report(), который публикует предупреждение или ошибку (в зависимости от используемой конфигурации). Этот метод принимает единственный аргумент, который представляет собой объект, содержащий следующие свойства: (Подробнее читайте в документации ESLint)

Выведем 3 свойства:

  • node (текущий узел). Он служит двум целям: сообщить eslint, где произошла ошибка, чтобы пользователь увидел информацию о строке при запуске eslint /, выделенную в его IDE с плагином eslint. Но также что является узлом, чтобы мы могли манипулировать им или вставлять текст до/после
  • message : Сообщение, которое eslint сообщит об этой ошибке.
  • fix: Способ автофиксации этого узла
CallExpression (node) {
      if (
      node.callee.name === 'shallow' &&
      !(node.typeParameters && node.typeParameters.length)
      ) {
       context.report({
        node: node.callee, // shallow
        message: `enzyme.${node.callee.name} calls should be preceded by their component as generic. ` +
        "If this doesn't remove type errors, you can replace it with <any>, or any custom type.",
        fix: function (fixer) {
         // TODO
        },
       })
      }
    }

Давайте запустим eslint. Вау! Нам удалось вывести ошибку. Но мы хотели бы сделать еще один шаг и исправить код автоматически, либо с помощью флага eslint --fix, либо с помощью плагина eslint для вашего редактора. Давайте напишем этот метод fix!

Написание метода исправления

Во-первых, давайте напишем ранний возврат, который будет вставлять <any> после нашего ключевого слова small на случай, если мы не вызываем shallow() с элементом JSX.

Мы используем метод insertTextAfter для достижения такой вставки.

insertTextAfter(nodeOrToken, text) - вставляет текст после данного узла или токена

const hasJsxArgument = node.arguments && node.arguments.find((argument, i) => i === 0 && argument.type === 'JSXElement')
if (!hasJsxArgument) {fixer.insertTextAfter(node.callee, '<any>')}

После этого раннего возврата мы знаем, что у нас есть элемент JSX в качестве первого аргумента. Если это первый аргумент (а так и должно быть, shallow() принимает только элемент JSX в качестве первого аргумента, как мы видели в его типизации), давайте возьмем его и вставим как универсальный.

const expressionName = node.arguments[0].openingElement.name.name
return fixer.insertTextAfter(node.callee, `<${expressionName}>`)

Вот и все! Мы зафиксировали имя выражения JSX, с которым вызывается small(), и вставили его после ключевого слова small в качестве универсального.

Было бы неплохо увидеть его в действии, не так ли? Именно это мы и сделаем: пришло время использовать наше правило в проекте, который мы создали ранее!

Использование нашего пользовательского плагина

Вернемся к нашему проекту ast-learning, давайте свяжем наш локальный плагин eslint:

npm install ./some/path/in/your/computer устанавливает модуль узла по указанному вами пути. Очень полезно для разработки модулей локальных узлов!

npm install ../eslint-plugin-ast-learning

До сих пор, если мы линтим наш файл, который не должен пройти ling, запустив npm run lint или перейдя в index.test.tsx с нашим редактором, если в нем установлен плагин eslint, мы не увидим ошибок, так как мы не добавили плагин и правило

Давайте добавим их к нашему .eslintrc.js

module.exports = {
 "parser": "@typescript-eslint/parser",
 "parserOptions": {
  "ecmaVersion": 12,
  "sourceType": "module"
 },
 "plugins": [
  "@typescript-eslint",
  "ast-learning", // eslint-plugin-ast-learning
 ],
 "rules": {
  "ast-learning/require-enzyme-generic": 'error'
 }
}

Если вы снова запустите npm run lint или перейдете к файлу с вашей IDE, в котором есть плагин eslint, вы должны увидеть ошибки:

/Users/alexandre.gomes/Sites/ast-learning/index.test.tsx
  12:21  error  enzyme.shallow calls should be preceeded by their component as generic. If this doesn't remove type errors, you can replace it
 with <any>, or any custom type  ast-learning/require-enzyme-generic
  20:21  error  enzyme.shallow calls should be preceeded by their component as generic. If this doesn't remove type errors, you can replace it
 with <any>, or any custom type  ast-learning/require-enzyme-generic
✖ 2 problems (2 errors, 0 warnings)
  2 errors and 0 warnings potentially fixable with the `--fix` option.

Их можно исправить автоматически, интересно! Почему бы нам не попробовать?

❯ npm run lint -- --fix

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

Идти дальше

Если вы хотите узнать больше о пользовательских плагинах ESLint, вам нужно прочитать очень полную документацию ESLint.

Вы также можете добавить обширные тесты для своих правил, поскольку, как показывает опыт, автофиксы eslint (и кодмоды jscodeshift, тема другого поста) имеют множество пограничных случаев, которые могут сломать вашу кодовую базу. Тесты sine qua non не только являются надежными для ваших правил, но и вносят свой вклад в официальное правило 😉

Например, что, если у нас также есть вызов shallow где-то еще в нашей кодовой базе? Что, если мы также хотим сделать то же самое с методом mount (для которого нужен такой же универсальный), но иметь функции с именами mount где-то еще в кодовой базе? Мы, очевидно, не хотим изменять их. Пример в этом руководстве необходимо повторить, чтобы он работал и в этих случаях.