Gentle Introduction To ESLint Rules
Read more
Have you ever wondered how your editor can know if you used var
instead of let
? or that you missed adding alt
to the img
tag? Yes, that is ESLint ladies and gentlemen.
In this article, we're going to talk about how this is possible and how you can do something similar!
For you who ain't familiar with ESLint, I recommend that you read about it in advance and perhaps try to see it in action. The writing presumes that you have at least little knowledge in configuring ESLint.
All the code snippets are available in Replit - if you can't see the file in the caption, you've to manually navigate to it from the file explorer.
Problems
-
One day Javascript rolled out new ways to create a variable (using
let
andconst
) an after some investigation it was mandatory to adapt to the new way, your team agreed and left the office thinking you're crazy. The next day the team have forgotten and it was like nothing happened yesterday. -
New developer joined and was pretty excited to create its first PR, after the onboarding, the new hire created the PR with inline styles everywhere. The team doesn't like inline styles and considers it a bad practice thus the PR was abandoned with one big comment "HTML files are pretty without inline styles, please consider moving them to the styles files".
-
You're refactoring a legacy class trying to simplify the logic by extracting the master method that does everything based on parameters to a rather set of simple methods. Now the master method is marked as deprecated in favour of the simpler ones.
-
You're working on React SSR project, you've created a component that utilises the navigator object to get the user agent information, the moment the code runs an error appears from nowhere telling you that navigator is undefined because the component has been executed in the server -because of SSR nature-.
-
The team members often forgot to unsubscribe from RxJS observable, the memory halted, you blame Angular, and decides to move to React instead.
All these issues can be dealt with using ESLint. ESLint ain't magic that will instantly solve the code bugs and errors, but it can be a very useful tool to warn you about potential bugs.
Throughout this article, we'll write ESLint rules that address a variety of issues, so let's get to the point directly.
How ESLint Can Understand The Code
ESLint has two (of many) major concepts:
- Rule: an object that describes what is about to be validated/linted (More information later).
- Plugin: Node.js package that exposes a set of rules.
An ESLint rule has a create
function with one argument (the context
object) and returns a traverse object.
The context object contains additional functionality that is helpful for rules to do their jobs. As the name implies, the context object contains information that is relevant to the context of the rule.
The traverse object keys are AST selectors and the values are functions that take a Node
object.
{
create(context) {
return {
VariableDeclaration: (node) => {},
};
},
};
Bear with me for a bit, things will get clearer soon! the snippet above is the basis for other definitions.
To know what is the Node
object and how ESLint can provide it, we need to talk about CS terms.
Compiler
nope, I mean Compiler phases and not the definition of the Compiler.
Your code (The program) will get smashed down by several layers before it's finally understood by the hardware, these layers are:
- Scanning.
- Parsing.
- There're more but not mandatory to know now.
Would you like to read more about the compiler or read more about the compiler phases, I recommend this book
Scanning
The process of reading the source code a character at a time and chunking them into Tokens
for instance, when passing this code into the scanner
var foo = 'bar';
will produce the following tokens
Token { type: 'Keyword', value: 'var', start: 0, end: 3 },
Token { type: 'Identifier', value: 'foo', start: 4, end: 7 },
Token { type: 'Punctuator', value: '=', start: 8, end: 9 },
Token { type: 'String', value: '"bar"', start: 10, end: 15 }
Parsing
The process of reading the output of the Scanning phase (tokens) and outputting a tree data structure called AST or "Abstract syntax tree". go down to learn more about AST
for instance, the above tokens will produce
Node {
type: 'Program',
start: 0,
end: 15,
body: [
Node {
type: 'VariableDeclaration',
start: 0,
end: 15,
declarations: [Array],
kind: 'var'
}
],
sourceType: 'script'
}
Finally, we can answer the question what is the Node
object in the traverse function? simply put a Node is an object that holds information about a specific part of the program like the location of the variable foo
and its initializer in form of a tree structure.
Let's start with a simple example: prevent the developer from using the var
keyword, for that, we need to handle nodes of type VariableDeclaration
- based on the AST above the selector for the var
keyword is VariableDeclaration
- and when the handler function is invoked we need to warn the developer that var
shouldn't be used.
{
create(context) {
return {
VariableDeclaration: (node) => {
if (node.kind === "var") {
context.report({
node: node,
message: 'Use "let" instead.',
});
}
},
};
},
};
I guess that's enough about how ESLint process your code, now it's the time to get more involved in writing code.
ESLint rule structure as follows
type Rule = {
meta: {
hasSuggestion: boolean;
type: 'problem' | 'suggestion' | 'layout';
docs: {
description: string;
url: string;
category: string;
recommended: boolean;
};
fixable: 'code' | 'whitespace';
schema: [];
};
create: Function;
};
Check ESLint rule docs for complete details
We already covered the create function in the above section, so let's talk about meta
.
type
: If the rule is for a code structure use "layout", if it's to warn about potential bug/error use "problem", otherwise you might want to stick with "suggestion".- fixable: Indicates that the node that represents a segment of the code is fixable (can be fixed automatically) by adding
--fix
options (More about it later). - schema: In case you want to make the rule configurable (e.g. accept an option to what kind of variable is allowed) you've to add JSON schema.
- hasSuggestion: Indicates that the node that represents a segment of the code has suggestions go down.
- docs:
- description: Concise yet meaningful description of what the rule does.
- url: A link where the user can get more information about the rule (docs or spec).
category
andrecommended
out of this article's scope.
Let's modify the rule to let the developer decides which kind of variable to dismiss, the option can be accessed using the context
object.
To specify options, we need to write a valid JSON schema. the schema
property takes an array of JSON objects. More information about the schema
{
meta: {
type: "problem",
schema: [ // HERE
{
enum: ["var", "let"],
},
],
},
create(context) {
const [varKind] = context.options;
return {
VariableDeclaration: (node) => {
if (node.kind === varKind) {
const allowedVarKind = varKind === "var" ? "let" : "var";
context.report({
node: node,
message: `Use ${allowedVarKind} instead.`,
});
}
},
};
},
};
Let's see how ESLint can fix this code for us...
Rule Fixer
As we saw above the context.report
function is what warns the developer about the validity of a segment of code.
this function accepts an object that has the node (that represents the code to be reported), message (meaningful message describing why that code is being reported) and fix
function
Note: there're more properties that the report object can have, which we might explain as we go further and others would be out of this article's scope.
The fix function receives a single argument, a fixer object, that you can use to apply a fix, it returns a fixing object (the return value of the fixer object methods) or array of fixing objects
Completing the previous example this is how we can auto-fix the reported code. We need to adjust the meta
object to have the fixable
option and add the fix
function to the report object.
{
meta: {
...,
fixable: "code"
}
create(context) {
const [varKind] = context.options;
return {
VariableDeclaration: (node) => {
if (node.kind === varKind) {
const allowedVarKind = varKind === "var" ? "let" : "var";
context.report({
node: node,
message: `Use ${allowedVarKind} instead.`,
fix(fixer) {
const start = node.start;
const end = node.start + 3;
const range = [start, end];
return fixer.replaceTextRange(range, allowedVarKind);
}
});
}
},
};
},
};
Note: you shouldn't determine the node range that way, for the sake of not complicating the code, I've added 3 to the start position, for instance, if var
start position is 5 then the end is 8 because var
length is 3. Check out [the official rule] (https://github.com/eslint/eslint/blob/main/lib/rules/no-var.js)
It has a few methods, each return a fixing
object to mutate the source code, you can either pass the token/node or the range (location) of a token/node.
In case you returned an array of fixing objects, make sure that the fixes ain't stepping on each other, otherwise, ESLint will refuse to apply the fixes for that node/token. ESLint can determine the conflicts by inspecting the node/token range of all the fix attempts, if the ranges step on each other ESLint will discard all fixes for that node.
for instance, the fixes in this sample conflicting on the range [4, 5]
fix(fixer) {
return [
fixer.replaceTextRange([1, 5], "const"), // changes the var kind to const
fixer.replaceTextRange([4, 7], "bar") // changes the var name to bar
]
}
Be wary to add the fixable
option to meta
object, otherwise, ESLint will refuse to do any fixes.
Fixer object methods.
insertTextAfter(nodeOrToken, text)
- inserts text after the given node or tokeninsertTextAfterRange(range, text)
- inserts text after the given rangeinsertTextBefore(nodeOrToken, text)
- inserts text before the given node or tokeninsertTextBeforeRange(range, text)
- inserts text before the given rangeremove(nodeOrToken)
- removes the given node or tokenremoveRange(range)
- removes text in the given rangereplaceText(nodeOrToken, text)
- replaces the text in the given node or tokenreplaceTextRange(range, text)
- replaces the text in the given range
Check the examples to see how the fixer object can be used.
Note: ESLint does not mutate the source code directly rather it does mutate the AST and regenerates the code from it
Rule Suggestions
What if we want to tell the developer that the code is fine but there might better way to do it. In this case, we shouldn't auto-fix the code, instead, you can provide a suggestion!
for instance, we want to suggest that instead of using the ternary operator you should use the normal if
statement.
const fooOrBar = true ? 'Foo' : 'Bar';
First thing first, we need to know what the AST of the above code looks like, for that we'll use AST Explorer.
The ternary operator node type is ConditionalExpression
, so we'll use that as an AST selector
Second, the type in meta
object needs to be suggestion
. You only need hasSuggestions
when the rule does suggest a fix or more.
{
meta: {
type: "suggestion",
hasSuggestions: false,
},
create(context) {
return {
ConditionalExpression: (node) => {
context.report({
node: node,
message: `What about using if instead?`,
});
},
};
},
};
Let's take another example but with real suggestions now. the next rule will suggest that if the var/let
is not being reassigned then it should be const instead.
- The
hasSuggestions
is true because the rule suggests fixes, also instead of addingfix
function as part of the report object, we added it as part of the suggestion object in thesuggest
array. - The context object has been used before to get the developer options and now it's used to get the whole source code.
- The source code has plenty of useful methods that we are using to get access to the tokens
- In a variable declaration, the first token is the variable keyword token and the next one is variable identifier token hence skip the first token.
{
meta: {
type: "suggestion",
hasSuggestions: true,
},
create(context) {
return {
VariableDeclaration: (node) => {
if (node.kind !== "const") {
const sourceCode = context.getSourceCode();
// the name/identifier of the var/let;
const varName = sourceCode.getFirstToken(node, { skip: 1 }).value;
/// continue in the next sample ...
}
},
};
},
};
We've to get all tokens that reference that variable and check if any of them have equal operator as direct sibling and if so then that variable cannot be const
.
const getAllUsageOfThisVar = sourceCode.getTokensAfter(node, {
filter: nextToken => nextToken.value === varName,
});
const reassigned = getAllUsageOfThisVar.some(token => {
const directNextToken = sourceCode.getTokenAfter(token);
return directNextToken.value === '=';
});
In the fix function, get the variable keyword token (we are interested in its exact location in the source code) then replace the text with const.
if (!reassigned) {
context.report({
node: node,
message: `Use const instead.`,
suggest: [
{
desc: 'You are not reassigning this variable, would you like to change it to const?',
fix: function (fixer) {
const varToken = sourceCode.getFirstToken(node, {
skip: 0,
}); // the var/let token
return fixer.replaceText(varToken, 'const');
},
},
],
});
}
Suggestions are very helpful in the editor experience where it'll show you dropdown of fixes that you can choose from.
ESLint Parser
ESLint doesn't process the code into the compiler phases, rather it provides an option to let you specify a Parser. By default, ESLint uses Espree which essentially converts JS source code to AST data structure, so in case you want to write a rule targeting TypeScript source code, you'll need to specify a different parser in your .eslintrc.json
configuration file, same applies for different file extension, for HTML you might use this or creating your own parser!
html-parser.js
exports.parseForESLint = function (code, options) {
return {
ast: require('parse5').parse(code),
services: {},
scopeManager: null,
visitorKeys: null,
};
};
Most properly you'll find parsers for all popular extensions - A custom parser is useful in case you want to enrich the generated AST with other properties related to your project or if you're building a custom file extension.
Rule Tester
What about test, I know you like to write test ^^ and that's why we'll talk about it now...
Unit testing rules is easier and explicit than the traditional test cases that you would write.
All you have to do is to prepare the valid and invalid cases and make sure they are always as stated. Let's go back to use-let rule and write test for it.
A sample code that use-let rule will find valid
let foo = 'bar';
A sample code that use-let rule will find invalid
var foo = 'bar';
What we need to do now is use RuleTester
to verify this requirement, it takes an object similar to the one you'd use in the in top level .eslintrc.json
.
This class is wrapper over mocha testing library.
The valid
and invalid
options take an array of cases. The easier way to write a valid test case is by using the static only
method, it takes the program to run the test against.
The invalid test case are bit more rich, you've to add the invalid program as well as the errors array that should present, the errors are can be as simple as message or more complex by adding the location and node type and so on
const rule = require('./use-let.rule.js');
const { RuleTester } = require('eslint');
const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 2020,
},
});
ruleTester.run('use-let', rule, {
valid: [RuleTester.only(`let foo = "bar";`)],
invalid: [
{
code: `var foo = "bar";`,
errors: [{ message: 'Use "let" instead' }],
},
],
});
Plugins Folder Structure
This is an opinionated folder structure and nothing like what ESLint recommend
Will start with folder in the root workspace named "plugins", each folder under this one is a plugin folder with all files related to it. A plugin is essentially an npm package that you can either publish it to a registry or link it locally. All the examples in this article are linked locally.
You do not have to use npm link for that, only add the plugin name and the path to it.
Folder structure tree for ESLint plugins.
plugins
└── <plugin-name>
├── rules
│ ├── <rule-name> # Each rule should have all the required files
│ │ ├── use-let.rule.js # use-let as an example
│ │ ├── use-let.test.js
│ │ └── use-let.docs.md # It's always good to have docs.
│ └── <rule-name-2> # Each rule should have all the required files
│ ├── prefer-const.rule.js
│ ├── prefer-const.test.js # Make sure to test every case!
│ └── prefer-const.docs.md
├── index.js # exports the plugin rules
└── package.json # look below.
The package.json
file should have at least the name
"eslint-plugin-'plugin-name'" and main
pointing to the rules exports file (index.js).
Examples
Let's write few more examples of ESLint rules and discover their potential.
Image alternative text (img alt)
Because we're validating HTML code we've to use a parser that can transform the HTML code to AST, so for that we'll use eslint-html-parser and add it in the .eslintrc.json file.
This rule will report a message in case img
tag doesn't have the alt
property.
Here's a sample HTML code
<img src="..." />
The AST is - some properties omitted for brevity.
{
type: 'HTMLElement',
tagName: 'img',
range: [ 0, 14 ],
children: [],
attributes: [
{
type: 'HTMLAttribute',
range: [Array],
attributeName: [Object],
attributeValue: [Object],
value: 'src=""',
}
]
}
- In the
meta.docs
we addedurl
property that give more info about the rule. - The AST selector of an HTML element is
HTMLElement
. - The
if
ignores all element that are notimg
tag. - If the
img
element have analt
regardless if it does have value or not then we do not report.
{
meta: {
type: "problem",
docs: {
description: "Img tag should have an alt property",
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#accessibility_concerns",
}
},
create: function (context) {
return {
HTMLElement: function (node) {
if (node.tagName === "img") {
const hasAltAttribute = node.attributes.some(
(item) => item.attributeName.value === "alt"
);
if (!hasAltAttribute) {
context.report({
node: node,
message: "Where is the alt? consider adding empty alt in case the img doesn't convey any meaning.",
});
}
}
},
};
},
};
Figure Tag
This rule will report a message in case img
tag is not within figure
tag.
- The first
if
ignores all element that are notimg
tag. - We walk all the way up till we find
figure
tag parent.
{
meta: {
type: "problem",
...other propreties
},
create: function (context) {
return {
HTMLElement: function (node) {
if (node.tagName === "img") {
let parent = node.parent;
while (parent && parent.tagName !== "figure") {
parent = parent.parent;
}
if (parent?.tagName !== "figure") {
context.report({
node: node,
message: "img tag should be wrapped within figure tag.",
});
}
}
},
};
},
};
Wait! challenge for you, complete the rule to report a message in case figcaption
is not within the figure
tag.
No Console
This rule will report a message whenever console method is present.
This sample code
console.log('Are you bored yet?');
Will give the following AST - some properties omitted for brevity.
Node {
type: 'CallExpression',
callee: {
type: 'MemberExpression',
object: {
type: 'Identifier',
name: 'console',
}
property: {
type: 'Identifier',
name: 'log',
}
}
}
So in order to check if any method from the console object is used we have to use CallExpression
as AST selector and in the handler we need to inspect the callee property to make sure the method is part of the console
object and if so we report an error.
{
meta: {
type: "problem",
},
create(context) {
return {
CallExpression: (node) => {
if (node.callee.object?.name === "console") {
context.report({
node: node,
message: `console.${node.callee.property.name} is cool but some people doesn't like it.`,
});
}
},
};
},
};
This of course won't cover all cases, for instance, code like the following won't be discovered by the logic we wrote above because we are no longer calling the console.log
directly thus we lost the CallExpression
signature. A proper solution is to report whenever a console method present and for that we've to use MemberExpression
AST selector.
The why we've not actually did this because I want you to be wary of such cases that can be easily forgotten.
const log = console.log;
log();
Comments
Let's see how we can leverage the comments and make them useful in different way.
In this example we want to report a message in case a declaration annotated with @private
tag is being used in specific directory (make sure to limit this rule to specific files in .eslintrc.json
).
/**
* @private Never use this function.
*/
function _internalFunction() {}
_internalFunction(); // an error will be reporated
/**
* @private and this constant
*/
const nil = null;
if (nil === null) {
// you should see warning in the console ^^
}
So in order to target all nodes we need to use the Program
node in the following steps
- Extract all tokens from it.
- Create a loop that starts from the first token.
- Ignore any token that isn't a
Keyword
(afaik, you can only annotate Keywords "function", "const", "class" and so on). - Ignore any keyword that doesn't have comments.
- Ignore in case the comments doesn't have
@private
annotation.
{
meta: {
type: "problem",
},
create(context) {
const sourceCode = context.getSourceCode();
return {
Program: (node) => {
const tokens = sourceCode.getTokens(node);
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (token.type !== "Keyword") {
continue;
}
const comments = sourceCode.getCommentsBefore(token);
if (!comments.length) {
continue;
}
const privateTagMessage = getCommentTagMessage(comments, "@private");
if (!privateTagMessage) {
continue;
}
}
},
};
},
};
Once we found a keyword with comments that have @private
tag we need to find all references to it.
- From the previous sample the
index
is pointing at the keyword token. - Get the keyword Identifier token (the identifier is always the next token after the keyword token).
- Create new loop that starts from the token after the keyword identifier to gather the references.
- Since we only need the references to the keyword token we need to ignore any token that is not Identifier or doesn't have the same value (identifier name) as the keyword identifier.
- Once we found a reference we report the private tag message
const keywordIdentifier = tokens[index + 1];
for (const nextToken of tokens.slice(index + 2)) {
if (
nextToken.type === 'Identifier' &&
nextToken.value === keywordIdentifier.value
) {
const message = privateTagMessage;
context.report({
node: nextToken,
message: `${keywordIdentifier.value}: ${message}`,
});
}
}
Deprecate a function
What about you creating this to test your understanding, here's how
- Create another folder in the plugins directory named deprecated.
- Create rules folder inside that directory and within it another folder named deprecate-function.
- In the deprecated folder create two files,
index.js
to export the rule andpackage.json
to make the plugin npm module. - In the deprecate-function folder create two files, deprecate-function.rule.js and deprecate-function.test.js.
The folder structure should look like this
plugins
└── deprecated
├── rules
│ └── deprecate-function
│ ├── deprecate-function.rule.js # rule logic
│ └── deprecate-function.test.js # rule unit test
├── index.js # exports the plugin rules
└── package.json # make the plugin npm package.
Now the rule should do the following
- Accepts an array of function names to deprecate (
scheme
option). - If the function name is not defined then ignore (don't report).
- use the
Identifier
selector and check if its name is exist in the functions names array (you need to usecontext.options
). - In case the
If
statement conditioned then report a message (you can make the message option as well!).
AST selectors
We've already talked about AST selector before so this is a complement to the writing.
You already know the CSS selectors like *
, []
, >
, ...etc, you actually can use those selectors along with the AST selectors, for instance, if you want to find variables with name foo you can use the attribute selector instead of doing an extra if
statement.
{
create(context) {
"VariableDeclaration[name="foo"]": () => { }
}
}
Postface
ESLint rules are not only a way to ensure the developer adheres to a specific style of coding, the way I see it, with ESLint you can build habits in the team members to do the things as routine and not a checklist of things they have to pass.
Following that, you can benefit from ESLint by creating rules that will help new joiners adapt easily to the codebase or folks from other teams who frequently participate in a codebase.
Final thought, ESLint rules can be developer guidelines for a codespace.
Bounce Sections
That's specially for you ^^ to enrich your information!
Abstract Syntax Tree (AST)
We already talked about compiler phase/stages and how each of them is essentially a step towards making the computer understands the code. One of these stages is Parsing which outputs AST.
AST is the source code in tree data structure that represents the relation between the tokens, for instance, this sample code
var foo = 'bar';
will output the following
As you can see there's root node Program
which is the source code (the variable foo), the body contains list of nodes because usually you've more than one statement 🙂. The VariableDeclaration
node is a statement that have the type of declaration and because it's variable and the variable have different kinds, there's kind
property.
In the declarations array there's Variable declarator that have information about the var identifier (name) and its initializer.
The node term is an abstract therefore each specific of the node have different properties related to it, for instance BinaryExpression
statement (5 + 1) will have left and right properties that have information about the operands an so on.
We've been utilising the AST so far for Static Code Analysis but that's not the only usage of it. You can also create code migration tools using the AST, like changing functions names or removing parameters and more complex usage.
AST Explorer
AST Explorer is an amazing website if you're working with ASTs, it has plenty of parsers for the most of things that you might hear about, for instance it has an option to parse SQL Query and GraphQL and many more.
This this the toolbar
Beside the AST Explorer logo we have 1. Snippet: In case you want to share, reset the state of the page, or save. (New button, will clear the url and Save will let you create new url to share) 2. JavaScript: A dropdown where you select the language/tool that you want to parse. 3. espree: A dropdown where you select the a library to parse the selected language/tool. 4. transform toggle: some languages/tools can be transformed to older version or different syntax. This option will be enabled in case transformation is supported for the selected language/tool.