Open19

ESLint独自ルールを作ってみたときのメモ

meijinmeijin

全体の手順

  • 独自ルールを作成して、rulesなどの決まったディレクトリに置く
  • npm scriptsでは"lint": "eslint --rulesdir rules"のように、rulesdirを指定する
meijinmeijin

独自ルールの作り方

  • 基本的には本家ESLintの各ルールを読んだり、巷にあふれる独自ルールを参照するとよい
  • 公式ドキュメントもあるが、ものすごく長く理解に時間がかかるので、日本語の文献や実コードを読みつつ進めるのが良いと思う。ある程度理解すると、公式ドキュメントが手にとるようにわかる。

https://eslint.org/docs/developer-guide/working-with-rules

https://azu.github.io/JavaScript-Plugin-Architecture/ja/ESLint/

https://github.com/knowledge-work/eslint-plugin-strict-dependencies/blob/main/strict-dependencies/index.js

meijinmeijin

独自ルールの構造

  • metacreateから構成される大きなオブジェクトをexportするJS(or TS)ファイルが実態
  • ファイル名がルール名とイコールになる

たとえば、変数名の先頭アンダースコアを禁止したい場合は
rules/no-underscore-prefix.jsを作る。

/**
 * @author meijin
 */
"use strict";

/** @type {import('../shared/types').Rule} */
module.exports = {
    meta: {
        type: "suggestion",

        docs: {
            description: "hoge",
            recommended: false,
            url: "https://eslint.org"
        },

        schema: [],

        messages: {
            unexpected: "Unexpected {{name}}."
        }
    },

    create(context) {
        return {
            VariableDeclaration(node) {
                context.getDeclaredVariables(node).forEach(variable => {
                    const name = variable.name;

                    if (name.charAt(0) === "_") {
                        context.report({
                            node,
                            messageId: "unexpected",
                            data: { name }
                        });
                    }
                });
            }
        };

    }
};
meijinmeijin

createキーでは、最終的にASTの各Nodeに対してどういうLintを行うかをリスト化したオブジェクトを返す。

今回は変数宣言を見つけて、アンダースコアから始まっているか調べる例なので、VariableDeclarationキーに関数を入れて返す。

これらのキーの命名は、厳密にはESTreeというプロジェクトで定義されており、以下のようなMarkdownで一覧で見ることができる。

https://github.com/estree/estree/blob/master/es2015.md

そのことは以下のセクションで明示されており、ここからESTreeの定義リポジトリに飛ぶことができる。

https://eslint.org/docs/developer-guide/working-with-custom-parsers#the-ast-specification

meijinmeijin

今回のルールでは、変数宣言のノードを見つけても、そのノードの中からさらに個別の変数名を切り出す必要がある。これを実現するために、僕は当初Tokenを順にたどっていく実装を行い、オペレータ(演算子)が現れたらその左のTokenを使うといった実装をしようとしたが、変数を複数宣言する際に失敗した。

よほど込み入ったケースでなければ、Contextオブジェクトに便利な関数getDeclaredVariablesが定義されている。

https://eslint.org/docs/developer-guide/working-with-rules#the-context-object

meijinmeijin

getDeclaredVariablesは変数宣言なので、関数の宣言やImport文などにも適用できる便利なヘルパである。

getDeclaredVariables(node) - returns a list of variables declared by the given node. This information can be used to track references to variables.
If the node is a VariableDeclaration, all variables declared in the declaration are returned.
If the node is a VariableDeclarator, all variables declared in the declarator are returned.
If the node is a FunctionDeclaration or FunctionExpression, the variable for the function name is returned, in addition to variables for the function parameters.
If the node is an ArrowFunctionExpression, variables for the parameters are returned.
If the node is a ClassDeclaration or a ClassExpression, the variable for the class name is returned.
If the node is a CatchClause, the variable for the exception is returned.
If the node is an ImportDeclaration, variables for all of its specifiers are returned.
If the node is an ImportSpecifier, ImportDefaultSpecifier, or ImportNamespaceSpecifier, the declared variable is returned.

meijinmeijin

最終的に、Lintエラーないし警告として報告すべきと判断した場合は、以下のようにcontext.report関数でNodeとともにメッセージやデータを投げることができる。

                    if (name.charAt(0) === "_") {
                        context.report({
                            node,
                            messageId: "unexpected",
                            data: { name }
                        });
                    }
meijinmeijin

テストのルールを書く。今回はESLint公式リポジトリをクローンしてその中に直接テストとルールを書き足すことでしか検証しなかったが、実際はプロダクトコード側でもテストは書けると思う。

以下のようにテストケースを書く。

"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require("../../../lib/rules/no-underscore-prefix"),
   RuleTester = require("eslint").RuleTester;

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester();

ruleTester.run("no-underscore-prefix", rule, {
    valid: [
        "'hoge'",
        "var hoge = 'hoge';",
        "// var hoge = 'hoge';",
        { code: "let x,y = 'hoge';", env: { es6: true } },
        { code: "const hoge = 'hoge';", env: { es6: true } },
        { code: "const obj = { _name: 'hoge' };", env: { es6: true } },
        "if (true) { var hoge = 'hoge' }"
    ],
    invalid: [
        {
            code: "var _hoge = 'hoge';",
            errors: [{ messageId: "unexpected", data: { name: "_hoge" }, type: "VariableDeclaration", line: 1, column: 1 }]
        },
        {
            code: "const _hoge = 'hoge';",
            env: { es6: true },
            errors: [{ messageId: "unexpected", data: { name: "_hoge" }, type: "VariableDeclaration", line: 1, column: 1 }]
        },
        {

            code: `const hoge = "OK";

const _hoge = "NG";

const hoge2 = "OK";`,
            env: { es6: true },
            errors: [{ messageId: "unexpected", data: { name: "_hoge" }, type: "VariableDeclaration", line: 3, column: 1 }]
        },
        { code: "let _x,y = 'hoge';", env: { es6: true }, errors: [{ messageId: "unexpected", data: { name: "_x" }, type: "VariableDeclaration", line: 1, column: 1 }] },
        { code: "let x,_y = 'hoge';", env: { es6: true }, errors: [{ messageId: "unexpected", data: { name: "_y" }, type: "VariableDeclaration", line: 1, column: 1 }] },
        {
            code: "let _x,_y = 'hoge';",
            env: { es6: true },
            errors: [
                { messageId: "unexpected", data: { name: "_x" }, type: "VariableDeclaration", line: 1, column: 1 },
                { messageId: "unexpected", data: { name: "_y" }, type: "VariableDeclaration", line: 1, column: 1 }
            ]
        },
        { code: "const { _name } = { _name: 'hoge' };", env: { es6: true }, errors: [{ messageId: "unexpected", data: { name: "_name" }, type: "VariableDeclaration", line: 1, column: 1 }] },
        {
            code: "let _x, _y;",
            env: { es6: true },
            errors: [
                { messageId: "unexpected", data: { name: "_x" }, type: "VariableDeclaration", line: 1, column: 1 },
                { messageId: "unexpected", data: { name: "_y" }, type: "VariableDeclaration", line: 1, column: 1 }
            ]
        }
    ]
});

ポイントはes6以降をオプションで指定しないとエラーになることが多々あったこと。

meijinmeijin

ASTは抽象構文木と略され、単にプログラムを木構造で表現した構文木から、無駄なカッコなど言語の意味を損なわない情報を落とした木を指す。

meijinmeijin

ESTreeはES6までの定義のため、現実的にはacornなどの別プロジェクトを使うことが多い。Babelも独自のASTを拡張して使っているとのこと。

Babel uses an AST modified from ESTree, with the core spec located here.

meijinmeijin

contextの型定義
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/eslint/index.d.ts#L438

    interface NodeListener {
        ArrayExpression?: ((node: ESTree.ArrayExpression & NodeParentExtension) => void) | undefined;
        ArrayPattern?: ((node: ESTree.ArrayPattern & NodeParentExtension) => void) | undefined;
        ArrowFunctionExpression?: ((node: ESTree.ArrowFunctionExpression & NodeParentExtension) => void) | undefined;
        AssignmentExpression?: ((node: ESTree.AssignmentExpression & NodeParentExtension) => void) | undefined;
        AssignmentPattern?: ((node: ESTree.AssignmentPattern & NodeParentExtension) => void) | undefined;
        AwaitExpression?: ((node: ESTree.AwaitExpression & NodeParentExtension) => void) | undefined;
        BinaryExpression?: ((node: ESTree.BinaryExpression & NodeParentExtension) => void) | undefined;
        BlockStatement?: ((node: ESTree.BlockStatement & NodeParentExtension) => void) | undefined;
        BreakStatement?: ((node: ESTree.BreakStatement & NodeParentExtension) => void) | undefined;
        CallExpression?: ((node: ESTree.CallExpression & NodeParentExtension) => void) | undefined;
        CatchClause?: ((node: ESTree.CatchClause & NodeParentExtension) => void) | undefined;
        ChainExpression?: ((node: ESTree.ChainExpression & NodeParentExtension) => void) | undefined;
        ClassBody?: ((node: ESTree.ClassBody & NodeParentExtension) => void) | undefined;
        ClassDeclaration?: ((node: ESTree.ClassDeclaration & NodeParentExtension) => void) | undefined;
        ClassExpression?: ((node: ESTree.ClassExpression & NodeParentExtension) => void) | undefined;
        ConditionalExpression?: ((node: ESTree.ConditionalExpression & NodeParentExtension) => void) | undefined;