Как создать свой плагин ESLint в Typescript с помощью шаблона, тестов и публикации

Как создать свой плагин ESLint в Typescript с помощью шаблона, тестов и публикации

20 марта 2023 г.

Содержание

  1. Инициализировать репозиторий с помощью шаблона
  2. Исходная структура шаблона
  3. Добавление правил с помощью скриптов из шаблона
  4. Написать тесты для подключаемого модуля Eslint
  5. Записать правило Эслинта
  6. Небольшое объяснение AST
  7. Окончательный вариант
  8. Обновление документов с помощью скриптов
  9. Публикация плагинов
  10. Подключите его к своему приложению.

Фон

Я постараюсь написать туториал на основе моего PR в репозитории Reatom с пошаговым объяснением: https ://github.com/artalar/Reatom/pull/488

Если вы хотите узнать больше, вы можете прочитать выпуск https://github.com/artalar/reatom/issues/487.

Чтобы добавить немного контекста, Reatom — это библиотека управления состоянием. Атомы — это концепция в Reatom, библиотеке управления состоянием для React.

Что такое плагины и правила ESLint?

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

Каждый модуль rule имеет свойство meta, описывающее правило, и свойство create, определяющее поведение.

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

Давайте углубимся в код

Инициализировать репозиторий

Создание нового проекта TypeScript eslint

npx degit https://github.com/pivaszbs/typescript-template-eslint-plugin reatom-eslint-plugin 

Затем перейдите в новый каталог проекта и установите зависимости с помощью:

cd reatom-eslint-plugin && npm i

Я хочу быть хорошим мальчиком, поэтому запускаю git.

git init && git add . && git commit -m "init"

Затем откройте файл package.json и найдите поле name. Это поле необходимо, потому что оно будет основной точкой входа для вашего плагина, когда он будет использоваться. Вы можете изменить его на следующее:

"name": "eslint-plugin-reatom"

В качестве альтернативы вы можете использовать соглашение об именовании пакетов с ограниченной областью действия:

"name": "@reatom/eslint-plugin"

Исходная структура

- scripts // some automation to concentrate on writing rules
- docs
  - rules // here will be generated by npm run add-rule files
- src
  - configs
      recommended.ts // generated config 
  - rules
      // all your rules
index.ts // Connection point to your plugin, autogenerated by scripts/lib/update-lib-index.ts

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

/* DON'T EDIT THIS FILE. This is generated by 'scripts/lib/update-lib-index.ts' */
import { recommended } from './configs/recommended';
import exampleRule from './rules/example-rule'

export const configs = {
    recommended
};

export const rules = {
    'example-rule': exampleRule
};

Добавление правил и обновление документов

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

npm run add-rule atom-rule suggestion

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

Написание тестов

Как энтузиаст TDD (разработка через тестирование), мы начнем с создания нескольких простых тестов в файле tests/atom-rule.ts:

// tests/atom-rule.ts
tester.run('atom-rule', atomRule, {
    valid: [
        {
            code: 'const countAtom = atom(0, "countAtom");'
        },
    ],
    invalid: [
        {
            code: `const countAtom = atom(0);`,
            errors: [{ message: 'atom name is not defined' }]
        },
        {
            code: 'const countAtom = atom(0, "count");',
            errors: [{ message: 'atom name is defined bad'}]
        },
    ]
});

Если вы запустите тесты сейчас, они потерпят неудачу, потому что мы еще не внедрили atomRule.

Написание правила

atomRule определяет поведение правила. Вот простая реализация:

import { Rule } from "eslint";

const rule: Rule.RuleModule = {
  meta: {
    docs: {
      description: "Add name for every atom call", // simply describe your rule
      recommended: true, // if it's recommended, then npm run update will add it to recommmended config
    },
    type: "suggestion"
  },
  create: function (context: Rule.RuleContext): Rule.RuleListener {
    return {
        VariableDeclaration: node => { // listener for declaration, here we can specifiy more specific selector
            node.declarations.forEach(d => {
                if (d.init?.type !== 'CallExpression') return;
                if (d.init.callee.type !== 'Identifier') return;
                if (d.init.callee.name !== 'atom') return;
                if (d.id.type !== 'Identifier') return;
                // just guard everything that we don't need

                if (d.init.arguments.length <= 1) {
                    // show error in user code
                    context.report({ 
                        message: `atom name is not defined`,
                        // here we can pass what will be underlined by red/yellow line
                        node,
                    })
                }

                if (d.init.arguments[1]?.type !== 'Literal') return;
                // just another guard
                if (d.init.arguments[1].value !== d.id.name) {
                    context.report({ message: `atom name is defined bad`, node })
                }
            })
        }
    };
},
};

export default rule;

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

Чтобы лучше понять структуру AST вашего кода, вы можете использовать https://astexplorer.net/ или просто проанализированные узлы console.log.

Небольшое пояснение для лучшего понимания типов AST

Вот небольшое описание каждого идентификатора в небольшом примере:

const kek = atom('kek')

  1. Идентификатор: интерфейс TypeScript, представляющий узел идентификатора в AST.

  2. const kek = atom('kek'), kek, и atom являются узлами идентификаторов.< /p>

    2. Literal: интерфейс TypeScript, который представляет узел буквального значения (строка, число, логическое значение и т. д.) в AST. const kek = atom(‘kek’), ‘kek’ — это литерал

    .

3. CallExpression: интерфейс TypeScript, представляющий узел выражения вызова функции в абстрактном синтаксическом дереве (AST).

  1. В нашем примере atom(‘kek’) — это CallExpression, который состоит из atom — идентификатора и kek — литерала.

    4. VariableDeclarator: интерфейс TypeScript, представляющий узел декларатора переменных в AST

    .

  1. В нашем примере все выражение, кроме const, представляет собой VariableDeclarator kek = atom(‘kek’)

    5. Узел: интерфейс TypeScript, представляющий общий узел AST.

Или просто с помощью astexplorer

https://astexplorer.net/?embedable=true#/gist/7fe145026f1b15adefeb307427210d38/35f114eb5b9c4d3cb626e76aa6af7782927315ed

Окончательный вариант

Последние тесты

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

Как исправить правило?

Добавить простую строку в контекстный отчет.

узел — может быть фактическим узлом или диапазоном символов, которые вы хотите заменить.

replaceString — какой код вы ожидаете увидеть.

Не забудьте добавить fixable: 'code' или fixable: 'whitespace' для метатегов вашего правила.

Если вы не знаете, как это исправить с помощью eslint, просто попробуйте свой существующий проект.

Сам код

import { Rule } from "eslint";
import { CallExpression, Identifier, Literal, VariableDeclarator, Node } from 'estree';
import { isIdentifier, isLiteral } from "../lib";

type AtomCallExpression = CallExpression & { callee: Identifier, arguments: [Literal] | [Literal, Literal] }
type AtomVariableDeclarator = VariableDeclarator & { id: Identifier, init: AtomCallExpression }

const noname = (atomName: string) => `atom "${atomName}" should has a name inside atom() call`;
const invalidName = (atomName: string) => `atom "${atomName}" should be named as it's variable name, rename it to "${atomName}"`;

export const atomRule: Rule.RuleModule = {
    meta: {
        type: 'suggestion',
        docs: {
            recommended: true,
            description: "Add name for every atom call"
        },
        fixable: 'code'
    },
    create: function (context: Rule.RuleContext): Rule.RuleListener {
        let importedFromReatom = false;

        return {
            ImportSpecifier(node) {
                const imported = node.imported.name;
                // @ts-ignore
                const from = node.parent.source.value;
                if (from.startsWith('@reatom') && imported === 'atom') {
                    importedFromReatom = true;
                }
            },
            VariableDeclarator: d => {
                if (!isAtomVariableDeclarator(d) || !importedFromReatom) return;

                if (d.init.arguments.length === 1) {
                    reportUndefinedAtomName(context, d);
                } else if (isLiteral(d.init.arguments[1]) && d.init.arguments[1].value !== d.id.name) {
                    reportBadAtomName(context, d);
                }
            }
        };
    }
}

function isAtomCallExpression(node?: Node | null): node is AtomCallExpression {
    return node?.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'atom';
}

function isAtomVariableDeclarator(node: VariableDeclarator): node is AtomVariableDeclarator {
    return isAtomCallExpression(node.init) && isIdentifier(node.id);
}

function reportUndefinedAtomName(context: Rule.RuleContext, d: AtomVariableDeclarator) {
    context.report({
        message: noname(d.id.name),
        node: d,
        fix: fixer => fixer.insertTextAfter(d.init.arguments[0], `, "${d.id.name}"`)
    });
}

function reportBadAtomName(context: Rule.RuleContext, d: AtomVariableDeclarator) {
    context.report({
        message: invalidName(d.id.name),
        node: d,
        fix: fixer => fixer.replaceText(d.init.arguments[1], `"${d.id.name}"`)
    });
}

Как видите, в нем просто больше ошибок, защита типов и проверка импорта. И, конечно же, я делаю правило поправимым.

Обновление документов

Чтобы обновить документы, вы можете использовать следующую команду:

Эта команда обновит README.md и обновит документы для каждого правила (но вам нужно немного написать о каждом правиле в файле docs/{rule}).

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

Этап публикации

Убедитесь, что версия указана в файле package.json.

Пишите термином, если это не 1.0.0.

Тогда просто пишите в корень.

Все будет создано и опубликовано с указанным вами именем пакета.

Подключите его к своему приложению

Я называю свой пакет.

Итак, мне нужно установить его.

И добавить в мою конфигурацию .eslintrc.

И все просто работает (просто для reatom-eslint-plugin нужно везде писать “reatom” вместо “@reatom”).

Заключение

В этом руководстве мы рассмотрели процесс создания подключаемого модуля ESLint для библиотеки управления состоянием Reatom. Мы покрываем:

  1. Как написать плагин eslint на Typescript.
  2. Как покрыть это тестами.
  3. Как заставить его работать с параметром --fix.
  4. Как использовать мой шаблон.
  5. Как опубликовать подключаемый модуль eslint.
  6. Как добавить его в существующий репозиторий с помощью eslint

Ресурсы для дальнейшего изучения и изучения

  1. https://github.com/pivaszbs/typescript-template-eslint-plugin
  2. https://astexplorer.net/
  3. https://github.com/artalar/reatom/pull/488/files
  4. https://eslint.org/docs/latest/extend/plugins
  5. https://www.reatom.dev/
  6. https://github.com/artalar/reatom
  7. https://docs.npmjs.com/about-semantic-versioning

Удачи :)


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE