DEV Community

Cover image for Customizing ESLint: Utilizing Custom Rules
Benyam Simayehu
Benyam Simayehu

Posted on • Updated on

Customizing ESLint: Utilizing Custom Rules

Recently, while working on a project, I found myself repeatedly stumbling upon a small, yet annoying mistake. I kept using ./.. to navigate back to the previous page, which was unnecessary since we could achieve the same result without having ./ at the beginning of the relative path. I kept making this mistake again and again until my team and I decided to take proactive measures. We realized that leveraging custom Eslint rules could effectively enforce the desired behavior, freeing us from worrying about such minute details.

In this article, we will explore our approach to resolving the issue and the challenges we faced along the way. For detailed instructions on creating your own customized Eslint rules or plugins, please refer to the Eslint docs.

What did we resolve?

For this particular project, we are using react and react-router-dom. We will be utilizing the useNavigate() hook from react-router-dom for navigation.

import { useNavigate } from 'react-router-dom';

function Component() {
  const navigate = useNavigate();
  const handler = () => {
    navigate('./..');
  };

  return (
    <button type="button" onClick={handler}>Back</button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The above code snippet describes a simple react component that renders a back button. When the back button is clicked the navigate() function will programmatically navigate the user back to the previous path.

The correct usage of the navigate() function typically involves passing a string path as a parameter, such as navigate('path'). So what we wanted was to go through the code base and refactor all the navigation paths such as navigate('./path') to navigate('path'). Also, we wanted Eslint to automatically correct the navigation path errors while linting.

How did we do it?

So, our objective is to trigger the Eslint rule when the navigate() function is called. To achieve this, the first step is to write a basic Eslint rule. This rule exports a meta object that describes the rule and a create function. Please refer to Eslint rule docs for a better understanding of Eslint's custom rule structure. Also, you can use tools like https://astexplorer.net/ for testing your custom rules while working on them or going down this article.

module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description:
        "Enforce the correct way of using navigate('path'), shows error if navigate('./path') is used instead."
    },
    fixable: 'code',
    hasSuggestions: true
  },
  create: context => {
    return {};
  }
};
Enter fullscreen mode Exit fullscreen mode

The create function returns a simple object with methods as properties. Each property is used for representing an abstract syntax tree (AST) node, it can be a function declaration, variable assignment, or function call. ESLint calls each returned property to "visit" nodes while traversing the AST of the JavaScript code.

In our case, the method we will be exposing is CallExpression. The CallExpression method will be called when Eslint visits a 'function call' expression.

Image description

Since the CallExpression will be called for all 'function call' expressions we have to tone it to our needs. First, we check if the function is named 'navigate'. Then we check how many arguments the function has. In this case, it should only have one argument of type 'string'.

module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description:
        "Enforce the correct way of using navigate('path'), shows error if navigate('./path') is used instead."
    },
    fixable: 'code',
    hasSuggestions: true
  },
  create: context => {
    return {
      CallExpression(node) {
        const callee = node.callee;
        const args = node.arguments;

        if (
          callee.type === 'Identifier' &&
          callee.name === 'navigate' &&
          args.length === 1 &&
          args[0].type === 'Literal'
        ) {
          const argument = args[0].value;

          if (typeof argument === 'string' && argument.startsWith('./')) {
            const suggestion = argument.replace('./', '');
            context.report({
              node,
              message:
                'Incorrect usage "navigate({{ argumentText }})", please use "navigate({{ suggestionText }})" instead.',
              data: {
                argument,
                suggestion
              }
            });
          }
        }
      }
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

All the relevant information about the 'function call' can be obtained from the CallExpression‘s argument node.

If the mentioned conditions are satisfied, the code will verify if the string argument starts with ./ characters. If it does, the code will remove those characters from the navigate() function argument and 'report' the correct usage to Eslint.

What do you mean by 'report'?

The create function has a context object as an argument. This context object contains information that is relevant to the context of the rule such as the report method. The report method is the main method that publishes a warning or an error to Eslint. We can use this method to publish an error message or a suggestion when the above conditions are met. Also, if you want Eslint to automatically enforce and correct your mistakes on the fly, you can use the can pass a fix function as an argument to the report method.

Just when we thought we were done!

From our (Javascript) point of view, the argument passed to the navigate() function is of type 'string' but according to Eslint it can be either of type 'Literal' or 'TemplateLiteral'. So what is the difference between these two types?

Template literals are string literals delimited with backtick (`) characters.

Image description

Whereas 'Literal' is just plain old string type delimited with (') or (") characters.

Image description

If you check the previous code snippet you can quickly find out there is an issue with the code. The issue is the way we get the argument’s value of type 'TemplateLiteral' is different from 'Literal'. If you pass in an argument of type 'TemplateLiteral' to the navigate() function the Eslint rule will have a hard time identifying the issue.

Image description

From the above image, we can see the Eslint rule wasn’t able to identify the argument as a 'string' literal and suggest a correction. We know the argument will resolve to string but Eslint will identify it as 'TemplateLiteral'. In order to resolve this issue, we had to add an additional if-else clause to identify the argument's type and extract the argument's value properly.

The Fix.

The context object also has another crucial property, the sourceCode object. The sourceCode as the name implies is the source that was passed to ESLint. Using this object we can directly interact with the AST and retrieve what is required. In our case, we want to get the navigate() function's argument as a text rather than what Eslint provided.

`

let argument;
const type = args[0].type;
const argumentText = sourceCode.getText(args[0]);

if (type === 'Literal') {
  argument = args[0].value;
} else if (type === 'TemplateLiteral') {
  argument = argumentText.replace('`', '');
}
Enter fullscreen mode Exit fullscreen mode

`

The above code snippet checks, if the type of the argument is a 'string' Literal get the value from the args variable but if it's a type 'TemplateLiteral' it will try to get the argument as a text directly from the source code. Since the direct text value will also include backticks (`) we have to remove them.

So that is it, a couple of if-else clauses there the Eslint rule is ready.

module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description:
        "Enforce the correct way of using navigate('path'), shows error if navigate('./path') is used instead."
    },
    fixable: 'code',
    hasSuggestions: true
  },
  create: context => {
    const sourceCode = context.sourceCode;

    return {
      CallExpression(node) {
        const callee = node.callee;
        const args = node.arguments;

        if (
          callee.type === 'Identifier' &&
          callee.name === 'navigate' &&
          args.length === 1
        ) {
          let argument;
          const type = args[0].type;
          const argumentText = sourceCode.getText(args[0]);

          if (type === 'Literal') {
            argument = args[0].value;
          } else if (type === 'TemplateLiteral') {
            argument = argumentText.replace('`', '');
          }

          if (typeof argument === 'string' && argument.startsWith('./')) {
            const suggestionText = argumentText.replace('./', '');
            context.report({
              node,
              message:
                'Incorrect usage "navigate({{ argumentText }})", please use "navigate({{ suggestionText }})" instead.',
              data: {
                argumentText,
                suggestionText
              },
              fix(fixer) {
                return fixer.replaceText(args[0], suggestionText);
              }
            });
          }
        }
      }
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

In conclusion

Summing it up, you can utilize Eslint custom rules in different scenarios to improve your productivity and code efficiencies. Also, understanding how Eslint works can help to easily figure out a solution when you are faced with those pesky Eslint configuration issues. Hope this article inspired you to write your own custom rules :)

Top comments (0)