Table of Contents
- Initialize repo using a template
- Initial structure of a template
- Adding rules using scripts from a template
- Write tests for the Eslint plugin
- Write Eslint rule
- Small AST explanation
- Final variant
- Updating docs using scripts
- Plugin publishing
- 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’)
-
Identifier
: a TypeScript interface that represents an identifier node in an AST.-
const kek = atom(‘kek’), kek, and atom are Identifiers nodes.
-
-
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.
-
CallExpression
: a TypeScript interface that represents a function call expression node in an abstract syntax tree (AST).
-
In our example, atom(‘kek’) is a CallExpression, which consists of atom - Identifier and kek - Literal.
-
-
VariableDeclarator
: a TypeScript interface that represents a variable declarator node in an AST
-
In our example, the whole expression except const is VariableDeclarator kek = atom(‘kek’)
-
-
Node
: a TypeScript interface that represents a generic AST node.
Or simply using astexplorer
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:
- How to write an eslint plugin in Typescript.
- How to cover it with tests.
- How to make it work with the --fix option.
- How to use my template.
- How to publish the eslint plugin.
- How to add it to your existing repository with eslint
Resources for further learning and exploration
- https://github.com/pivaszbs/typescript-template-eslint-plugin
- https://astexplorer.net/
- https://github.com/artalar/reatom/pull/488/files
- https://eslint.org/docs/latest/extend/plugins
- https://www.reatom.dev/
- https://github.com/artalar/reatom
- https://docs.npmjs.com/about-semantic-versioning
Have fun :)