Writing an ESLint Rule Based on a Comment
In this post we'll be building an ESLint rule that validates a function with comments (specifically Function Declarations). We will create a custom eslint rule that traverses the JavaScript code, reads the function, and then validates the function based on the preceding comment node. For clarity's sake, let's call these functions with comments simply "interesting functions".
I will skip the ESLint project setup, and dive into the code we'll be using. You can see the finished rule on my github project page.
How Parsing Works
ESLint uses its own parser named espree which parses the javascript into an abstract syntax tree. The AST is a nested data structure that represents the JavaScript code. You can see the AST for a piece of JavaScript by pasting it into the astexplorer. Note that the nodes of the tree correspond to the syntax defined in the ECMA Script Specifications (ES5 or ES6).
There are two ways we can navigate the structure of the AST.
First we can write code that will run on specific nodes of the tree. We can query the nodes using ESLint Selectors. This is similar to how we would query the DOM using css selectors.
The second way is to run code when a code path is started or ended. Whenever a code path is started or ended, we have the opportunity to update the state of our ESLint rule. So we can know when the parser starts traversing a function and when it's finishing traversal.
Let's take a look at a barebones ESLint rule...
module.exports = {
meta: {},
create: function(context) {
return {
/**
* This is called at the start of analyzing a code path.
*
* @param {CodePath} codePath - The new code path.
* @param {ASTNode} node - The current node.
* @returns {void}
*/
"onCodePathStart": function(codePath, node) {
},
/**
* This is called at the end of analyzing a code path.
*
* @param {CodePath} codePath - The completed code path.
* @param {ASTNode} node - The current node.
* @returns {void}
*/
"onCodePathEnd": function(codePath, node) {
},
// Any Identifier inside a FunctionDeclaration node
"FunctionDeclaration Identifier": function(node) {
}
};
}
};
And now let's apply that to our goal of validating interesting functions. First we want to know when traversal of interesting functions start. Let's write a function named getFunctionComment
that will accept an AST node and a Context, and will return the comment if the node is a function and it has a leading comment. For our purposes, we'll concatenate multi-line comments into a single string.
// ASTNode -> Context -> Maybe String
function getFunctionComment(node, context) {
if (node.type.indexOf("FunctionDeclaration") !== -1) {
// Get the Node's source code...
var code = context.getSourceCode();
// Read the comments from the code. This is an
// object with properties "leading" and "trailing".
// The objects in the arrays also contain position data,
// but we just care about the "value" property.
// { leading :: Array { value :: String }
// , trailing :: Array { value :: String }
// }
var comments = code.getComments(node);
if (comments.leading.length > 0) {
return comments.leading.reduce(function(comment, line) {
comment += line.value.trim();
return comment;
}, '');
}
}
}
So now we can call getFunctionComment
inside the onCodePathStart function so we can see if we're traversing an interesting function. If we are, we'll save some information in the rule's state, which will act as a flag for our other code. And when onCodePathEnd is called we'll clear the state, indicating we're no longer traversing the interesting function.
module.exports = {
meta: {},
create: function(context) {
var interestingFunctions = {};
var latestCodePath;
return {
"onCodePathStart": function(codePath, node) {
var comment = getFunctionComment(node, context);
// This code path is an interesting function
if (comment) {
latestCodePath = codePath.id;
// Save the function information in a state
// variable
interestingFunctions[codePath.id] = {
functionName : node.id.name,
codePath: codePath,
node : node,
comment : comment
};
}
},
"onCodePathEnd": function(codePath, node) {
// Code path has ended, remove the code path
// from the state
interestingFunctions[codePath.id] = undefined;
latestCodePath = undefined;
}
}
}
Now that we have hooks to know when we're traversing interesting functions, we can add the code that will validate the function. Let's write a rule that doesn't allow a certain variable name inside the function. To do this, we'll look for all Identifier nodes and compare the Identifier's name to the name in the comment.
module.exports = {
meta: {},
create: function(context) {
var interestingFunctions = {};
var latestCodePath;
return {
"FunctionDeclaration Identifier": function(node) {
if (latestCodePath != null) {
// We know this node is inside a Function Declaration
// with a comment because latestCodePath will only
// be set when onCodePathStart has been run with a
// function that has a comment
var details = interestingFunctions[latestCodePath];
if (node.name === details.comment) {
// Tell ESLint there is an error
context.report(
node,
'Function contains forbidden variable "' + details.comment + '"'
)
}
}
}
}
}
}
Now we're ready to start testing our custom rule.
Rule Debugging and Development Process
Workflow
I experimented with two different workflows when working on my plugin.
First I just added console.log to my rule's code and examined the test output whenever I made a change. The second way, I ran the mocha tests with the remote debugger enabled. I added a "test-debug" task to my package.json file which added the --inspect-brk flag to the test command.
{
"scripts": {
"test": "mocha tests --recursive --watch",
"test-debug": "mocha tests --recursive --watch --inspect-brk"
}
}
Then in Chrome, I went to chrome://inspect/#devices
, and I saw the remote debugging server. I clicked on it and it opened a developer window which allowed me to add breakpoints within my rule's code.
Test Code
When you setup the mocha tests, you have to define the code that your rule will run against. You define which code will be valid and which will be invalid. I modified the standard ESLint test so it would use fs.readFile to read the javascript I wanted to test with. I created a folder named "test-code" and put my samples in there.
ruleTester.run("forbidden-variable", rule, {
valid: [],
invalid: [
{
code: fs.readFileSync(__dirname + '/test-code/forbidden-variable-error.js', 'utf-8'),
errors: [{
message: "Function contains forbidden variable \"voldemort\"",
}]
}
]
});
A More Detailed Explanation of Code Paths
I was trying to think of a good way to explain code paths. Think of code paths as all of the potential ways that your program can be executed. A segment is a part of the branching logic of the program. If you have an IfStatement, you'll have a path for the true condition, the false condition, and potentially other nested conditions via 'else if'. If you have a try-catch, you have the non-exception case and the exceptional one. If you have a function, that is a path with only one possibility.
The code path hooks allow you to run code whenever a new code path is encountered, and whenever a "segment" of the path is encountered.
Consider one of the examples on the (Code Path Analysis Page)[https://eslint.org/docs/developer-guide/code-path-analysis]...
if (a) {
foo();
} else if (b) {
bar();
} else if (c) {
hoge();
}
In the case of a chained IfStatement (if, else, else-if), first a new code path is encountered for the 'if'. Then you encounter a segment which would be the BlockStatement associated with the call foo(). The second segment would be the traversal to the next IfStatement associated with "else if (b)", where that IfStatement would spawn two other segments. And so forth down the chain. So if we want to analyze this, we can look at when we first encounter an IfStatement as well as each component of the IfStatement.
How to Setup an ESLint Project
There are several great posts to help you with project setup. The one I used was Creating an ESLint Plugin by Björn Tegelund. In short, you'll need to install Yeoman followed by the ESLint Generator.
npm i -g yo
npm i -g generator-eslint
Then created a new plugin and a rule in a new project folder.
yo eslint:plugin
yo eslint:rule
And you'll have a project structure like this...
docs
lib
- rules
- index.js
tests
- lib
- rules
package.json