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.
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.
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"
- 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
};
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.
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.
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.
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
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.
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
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.
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.
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.
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).
In this tutorial, we walked through the process of creating an ESLint plugin for the Reatom state management library. We cover:
Resources for further learning and exploration
Have fun :)