🧩 Creating a Custom ESLint Plugin

An ESLint plugin is an extension for ESLint that adds additional rules and configuration options. Plugins let you customize your ESLint configuration to enforce rules that are not included in the core ESLint package. Plugins can also provide additional environments, custom processors, and configurations.


Background

I'm using useRequest from ahooks to fetch data, however, it's not easy to track the dependent parameters of refreshDeps. One way is to declare a request function using useCallback, and then add this function as a dependency, but this way is not very convenient. This plugin adds tips like eslint-plugin-react-hooks for useRequest.

Adding a Rule

A basic rule starts with minimal codes as follows:

module.exports = {
  meta: {
    type: 'suggestion', // `problem`, `suggestion`, or `layout`
    docs: {
      description: 'verifies the list of dependencies for useRequest',
      recommended: true, // Specifies whether the "extends": "eslint:recommended" property in a configuration file enables the rule
      url: null, // URL to the documentation page for this rule
    },
    fixable: null, // Either `code` or `whitespace` if the --fix option on the command line automatically fixes problems reported by the rule
    hasSuggestions: true, // Specifies whether rules can return suggestions (defaults to false if omitted)
    schema: [], // Add a schema if the rule has options
  },
  create: function (context) {
    return {
      Program: function (node) {
        // Add the rule here
      },
    }
  },
}

And our rule would be something like this:

module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'verifies the list of dependencies for useRequest',
      recommended: true,
    },
    fixable: 'code',
    hasSuggestions: true,
    schema: [
      {
        type: 'object',
        additionalProperties: false,
        autoFix: false,
        properties: {
          autoFix: {
            type: 'boolean',
          },
        },
      },
    ],
  },
  create: function (context) {
    return {
      Program: function (node) {
        // Add the rule here
      },
    }
  },
}

You can check out the Custom Rules for more information.

Implementing the Rule

We would use the eslint-plugin-react-hooks plugin to track the dependencies, below is the final rule.

const reactHooksPlugin = require('eslint-plugin-react-hooks')
const reactHooksCreate = reactHooksPlugin.rules['exhaustive-deps'].create
const depKey = 'refreshDeps'
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'verifies the list of dependencies for useRequest',
      recommended: true,
    },
    fixable: 'code',
    hasSuggestions: true,
    schema: [
      {
        type: 'object',
        additionalProperties: false,
        autoFix: false,
        properties: {
          autoFix: {
            type: 'boolean',
          },
        },
      },
    ],
  },
  create(context) {
    return {
      /** @see https://astexplorer.net/ */
      CallExpression: function (node) {
        if (
          node.callee.type === 'Identifier' &&
          node.callee.name === 'useRequest'
        ) {
          const arg1 = node.arguments[1] // useRequest options
          let manual = false
          let refreshDeps
          let refreshDepsRange
          if (arg1) {
            if (arg1.type === 'ObjectExpression') {
              for (const prop of arg1.properties) {
                if (prop.key.name === 'manual' && prop.value.value) {
                  manual = true
                } else if (prop.key.name === depKey) {
                  refreshDeps = prop.value.elements
                  refreshDepsRange = prop.value.range
                }
              }
            } else {
              return
            }
          }
          if (manual) return
          node.arguments[1] = {
            type: 'ArrayExpression',
            elements: refreshDeps ?? [],
            loc: node.loc,
            parent: node.parent,
            range: refreshDepsRange ?? node.range,
          }
          const report = (problem) => {
            problem.message = problem.message.replace(
              /Either include (it|them) or remove the dependency array/,
              `Please add $1 to '${depKey}'`,
            )
            problem.message = problem.message.replace(
              /Either exclude (it|them) or remove the dependency array/,
              `Please remove $1 from '${depKey}'`,
            )
            const suggest = problem.suggest[0]
            const fix = (fixer) => {
              if (refreshDepsRange) {
                return suggest.fix(fixer)
              }
              let depsStr
              suggest.fix({
                replaceText: function (_, deps) {
                  depsStr = deps
                },
              })
              const replacementText = `${depKey}: ${depsStr}`
              if (arg1) {
                const arg1Text = context.sourceCode.getText(arg1)
                return fixer.replaceText(
                  arg1,
                  `${arg1Text.replace(
                    /,?\s*\}\s*,?$/,
                    arg1.properties.length ? ',' : '',
                  )} ${replacementText} }${arg1Text.endsWith(',') ? ',' : ''}`,
                )
              }
              const nodeText = context.sourceCode.getText(node)
              return fixer.replaceText(
                node,
                `${nodeText.replace(
                  /,?\s*\)\s*,?$/,
                  ',',
                )} { ${replacementText} })${nodeText.endsWith(',') ? ',' : ''}`,
              )
            }
            problem.fix = context.options[0]?.autoFix && fix
            problem.suggest = [
              {
                desc: suggest.desc,
                fix,
              },
            ]
            context.report(problem)
          }
          const reactHooksCallExpression = reactHooksCreate({
            options: [{ additionalHooks: `(useRequest)` }],
            report,
            getScope: context.getScope,
            getSource: context.getSource,
            getSourceCode: context.getSourceCode,
            sourceCode: context.sourceCode,
          }).CallExpression
          reactHooksCallExpression(node)
        }
      },
    }
  },
}

How to Use It

In the plugin, add the rule to recommend,

.eslintrc
{
  "extends": [
    ...
    "plugin:<plugin-name>"
  ],
  "rules": {
    ...
    "<plugin-name>/<rule-name>": 1
  }
}