How to Create Your ESLint Plugin in Typescript With a Template, Tests, and Publication

Written by antonkrylov322 | Published 2023/03/20
Tech Story Tags: coding | eslint | eslint-configuration | typescript | how-to-eslint | coding-skills | coding-tutorial | hackernoon-top-story | hackernoon-es | hackernoon-hi | hackernoon-zh | hackernoon-vi | hackernoon-fr | hackernoon-pt | hackernoon-ja

TLDRWrite eslint plugin by yourself using typescript with tests and template. You will know more about AST and how to deal with it in typesccript (yeah it's easy if you know it well). Also you may walk through my PR and history of commits for understanding history of thoughts of this article :)via the TL;DR App

Table of Contents

  1. Initialize repo using a template
  2. Initial structure of a template
  3. Adding rules using scripts from a template
  4. Write tests for the Eslint plugin
  5. Write Eslint rule
  6. Small AST explanation
  7. Final variant
  8. Updating docs using scripts
  9. Plugin publishing
  10. Connect it with your application

Background

I will try to write a tutorial based on my PR in the Reatom repository with a step-by-step explanation: https://github.com/artalar/Reatom/pull/488

If you want to know more, you can read the issue https://github.com/artalar/reatom/issues/487.

To add a bit of context, Reatom is a state management library. Atoms are a concept in Reatom, a state management library for React.

What Are the ESLint Plugins and Rules?

ESLint plugins are extensions that work with the core ESLint package to enforce specific coding standards. Plugins contain a rules folder, which defines individual rules for enforcing these standards.

Eachrule module has ameta property that describes the rule and a create property that defines the rule's behavior.

Thecreate function takes a context argument, which is used to interact with the code being checked, and you can use it to define the logic of your rule, like requiring strict naming conventions for your library.

Let’s Dive Into the Code

Initialize Repo

Creating a new TypeScript eslint project

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

Then, navigate to the new project directory, and install the dependencies with:

cd reatom-eslint-plugin && npm i

I want to be a good boy, so I init git.

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

Next, open thepackage.json file, and locate the name field. This field is essential because it will be the main entry point for your plugin when it's used. You can change it to the following:

"name": "eslint-plugin-reatom"

Alternatively, you can use the scoped package naming convention:

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

Initial Structure

- 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

In general index, files will be generated by scripts, so you don’t need to worry about it 😀

/* 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
};

Adding Rules and Updating Docs

In this repository, you'll find some convenient scripts for adding rules and updating documents. To add a new rule, you can use the following command:

npm run add-rule atom-rule suggestion

This will generate three sections for the new rule: documentation, tests, and actual code. We can skip the documentation section for now and focus on the last two.

Write Tests

As a TDD (test-driven development) enthusiast, we'll start by creating some simple tests in the tests/atom-rule.ts file:

// 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'}]
        },
    ]
});

If you run the tests now, they will fail because we haven't implemented the atomRule yet.

Writing the Rule

The atomRule is where we define the rule's behavior. Here's a simple implementation:

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;

It’s a simple variant, but here, we can easily understand what’s going on.

For a better understanding of the AST structure of your code, you can use https://astexplorer.net/ or simply console.log parsed nodes.

A Small Explanation for AST Typings Better Understanding

Here's a small description of each identifier in a small example:

const kek = atom(‘kek’)

  1. Identifier: a TypeScript interface that represents an identifier node in an AST.

    1. const kek = atom(‘kek’), kek, and atom are Identifiers nodes.

  2. Literal: a TypeScript interface that represents a literal value (string, number, boolean, etc.) node in an AST. const kek = atom(‘kek’), ‘kek’ is a Literal.

  3. CallExpression: a TypeScript interface that represents a function call expression node in an abstract syntax tree (AST).

    1. In our example, atom(‘kek’) is a CallExpression, which consists of atom - Identifier and kek - Literal.

  4. VariableDeclarator: a TypeScript interface that represents a variable declarator node in an AST

    1. In our example, the whole expression except const is VariableDeclarator kek = atom(‘kek’)

  5. Node: a TypeScript interface that represents a generic AST node.

Or simply using astexplorer

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

Final Variant

The final tests

tester.run('atom-rule', rule, {
  valid: [
      {
          code: `
          import { atom } from '@reatom/framework'
          const countAtom = atom(0, "countAtom");
          `
      },
      {
          code: `const countAtom = atom(0);`,
      },
      {
          code: 'const countAtom = atom(0, "count");',
      },
  ],
  invalid: [
      {
          code: `
          import { atom } from '@reatom/framework'
          const countAtom = atom(0);
          `,
          errors: [{ message: 'atom "countAtom" should has a name inside atom() call', }],
          output: `
          import { atom } from '@reatom/framework'
          const countAtom = atom(0, "countAtom");
          `,
      },
      {
          code: `
          import { atom } from '@reatom/framework'
          const countAtom = atom(0, "count");
          `,
          errors: [{ message: `atom "countAtom" should be named as it's variable name, rename it to "countAtom"` }],
          output: `
          import { atom } from '@reatom/framework'
          const countAtom = atom(0, "countAtom");
          `,
      },
  ]
});

From tests, we understand that we need somehow change the source code by using our rule.

How to Make Your Rule Fixable?

Add a simple line to the context report.

fix: fixer => fixer.replaceText(node, replaceString)

node - may be an actual node or range of symbols that you want to replace.

replaceString - what code you expect to see.

Don’t forget to add fixable: 'code' or fixable: 'whitespace' for your rule meta tags.

If you are not familiar with how to fix it with eslint, just try on your existing project.

eslint --fix ./src

Code Itself

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}"`)
    });
}

As you can see, it just has better errors, type guards, and includes import checking. And, of course, I make the rule fixable.

Updating the Docs

To update the documents, you can use the following command:

npm run update

This command will update README.md and update docs for each rule (but you need to write a bit about each rule in the docs/{rule} file).

Also, as I said, you don’t need to worry about the index file.

Publish Step

Ensure the version is in your package.json.

  "version": "1.0.0"

Write in term if it’s not 1.0.0.

npm version 1.0.0

Then just write in the root.

npm publish

Everything will be built and published with your defined package name.

Connect It With Your Application

I name my package.

@reatom/eslint-plugin

So, I need to install it.

npm i @reatom/eslint-plugin

And add to my .eslintrc config.

module.exports = {
    plugins: [
        "@reatom"
    ],
// use all rules
     extends: [
        "plugin:@reatom/recommended"
     ],
    // or pick some
    rules: {
        '@reatom/atom-rule': 'error',
        // aditional rules, you can see it in PR
        '@reatom/action-rule': 'error',
        '@reatom/reatom-prefix-rule': 'error'
    }
}

And everything just works (for just reatom-eslint-plugin you should write “reatom” instead “@reatom" everywhere).

Conclusion

In this tutorial, we walked through the process of creating an ESLint plugin for the Reatom state management library. We cover:

  1. How to write an eslint plugin in Typescript.
  2. How to cover it with tests.
  3. How to make it work with the --fix option.
  4. How to use my template.
  5. How to publish the eslint plugin.
  6. How to add it to your existing repository with eslint

Resources for further learning and exploration

  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

Have fun :)


Written by antonkrylov322 | Frontend developer with taste of beer
Published by HackerNoon on 2023/03/20