ESLint

この文章はESLint v7.8.1を元に書かれています。

ESLintはJavaScriptのコードをJavaScriptで書かれたルールによって検証するLintツールです。

大まかな動作としては、検証したいJavaScriptのコードをパースしてできたAST(抽象構文木)をルールで検証し、エラーや警告を出力します。

このルールがプラグインとして書くことができ、ESLintのすべてのルールはプラグインとして実装されています。

今回はESLintのプラグインアーキテクチャがどうなっているかを見て行きましょう。

どう書ける?

ESLintでは.eslintrcという設定ファイルに利用するルールを設定して利用します。そのため、実行方法についてはドキュメントを参照してください。

ESLintにおけるルールとは、次のようなcreateメソッドをもつオブジェクトをexportしたモジュールです。createメソッドにはcontextオブジェクトが渡されるので、それに対して1つのオブジェクトを返すようにします。

module.exports = {meta: {/* ルールのメタ情報 */ },create:function (context){return {"MemberExpression":function (node){if (node.object.name ==="console") {                    context.report({                        node,message:"Unexpected console statement."                    });                }            }        };    }};

ESLintではコードを文字列ではなくASTを元にチェックしていきます。ASTについてはここでは詳細を省きますが、コードをJavaScriptのオブジェクトで表現した木構造のデータだと思えば問題ないと思います。

たとえば、

console.log("Hello!");

というコードをパースしてASTにすると次のようなオブジェクトとして取得できます。

{"type":"Program","body": [        {"type":"ExpressionStatement","expression": {"type":"CallExpression","callee": {"type":"MemberExpression","computed":false,"object": {"type":"Identifier","name":"console"                    },"property": {"type":"Identifier","name":"log"                    }                },"arguments": [                    {"type":"Literal","value":"Hello!","raw":"\"Hello!\""                    }                ]            }        }    ],"sourceType":"script"}

ESLintではこのASTを使って、no-console.jsのようにconsole.logなどがコードに残ってないかなどをルールを元にチェックできます。

ルールをどう書けるかという話に戻すと、contextというオブジェクトはただのユーティリティ関数の集合と考えて問題ありません。ルールの本体はcreateメソッドがreturnしてるメソッドをもったオブジェクトです。

このオブジェクトはNodeのtypeをキーとしたメソッドを持っています。そして、ASTを探索しながら「"MemberExpression" typeのNodeに到達した」と登録したルールに対して通知(メソッド呼び出し)を繰り返しています。

先ほどのconsole.logのASTにおけるMemberExpression typeのNodeとは次のオブジェクトのことを言います。

{"type":"MemberExpression","computed":false,"object": {"type":"Identifier","name":"console"    },"property": {"type":"Identifier","name":"log"    }}

no-console.jsのルールを見るとMemberExpression typeのNodeがnode.object.name === "console" となった場合に、consoleが残ってると判断してエラーレポートすると読めてくると思います。

ASTの探索がイメージしにくい場合は、次のルールで探索の動作を見てみると分かりやすいかもしれません。

functiondebug(string){console.log(string);}debug("Hello");

その他、ESLintのルールの書き方についてはドキュメントや次の記事を見てみるといいでしょう。

どのような仕組み?

ESLintはコードをパースしてASTにして、そのASTをJavaScriptで書いたルールを使いチェックするという大まかな仕組みは分かりました。

次に、このルールをプラグインとする仕組みがどのように動いているのか見て行きましょう。

ESLintのLintは次のような3つの手順で行われています。

  1. ルール毎に使っているNode.typeをイベント登録する
  2. ASTをtraverseしながら、Node.typeのイベントを発火する
  3. ルールからcontext.report()された内容を集めて表示する

このイベントの登録と発火にはEventEmitterを使い、ESLint本体に対してルールは複数あるので、典型的なPub/Subパターンとなっています。

擬似的なコードで表現すると次のような流れでLintの処理が行われています。

import {parse}from"esprima";import {traverse}from"estraverse";import {EventEmitter}from"events";functionlint(code){// コードをパースしてASTにするconst ast = parse(code);// イベントの登録場所const emitter =new EventEmitter();const results = [];    emitter.on("report", message => {// 3. のためのreportされた内容を集める        results.push(message);    });// 利用するルール一覧const ruleList = getAllRules();// 1. ルール毎に使っている`Node.type`をイベント登録する    ruleList.forEach(rule => {// それぞれのルールに定義されているメソッド一覧を取得// e.g) MemberExpression(node){}// => {"MemberExpression" : function(node){}, ... } というオブジェクトconst methodObject = getDefinedMethod(rule);Object.keys(methodObject).forEach(nodeType => {            emitter.on(nodeType, methodObject[nodeType]);        });    });// 2. ASTをtraverseしながら、`Node.type`のイベントを発火する    traverse(ast, {// 1.で登録したNode.typeがあるならここで呼ばれる        enter:(node) => {            emitter.emit(node.type, node);        },leave:(node) => {            emitter.emit(`${node.type}:exit`, node);        }    });// 3. ルールから`context.report()`された内容を集めて表示するconsole.log(results.join("\n"));}

Pub/Subパターンを上手く使うことで、ASTを走査するのが一度のみで、それぞれのルールに対してどのようなコードかという情報がemitで通知できていることがわかります。

もう少し具体的にするため、実装して動かせるようなものを作ってこの仕組みについて見ていきます。

実装してみよう

今回は、ESLintのルールを解釈できるシンプルなLintの処理を書いてみます。

利用するルールは先ほども出てきたno-console.jsをそのまま使い、このルールを使って同じようにJavaScriptのコードを検証できるMyLinterを書いてみます。

MyLinter

MyLinterは単純な2つのメソッドをもつクラスとして実装しました。

  • MyLinter#loadRule(rule): void
    • 利用するルールを登録する処理
    • ruleno-console.jsがexportしたもの
  • MyLinter#lint(code): string[]
    • codeを受け取りルールによってLintした結果を返す
    • Lint結果はエラーメッセージの配列とする

実装したものが次のようになっています。

import { parse }from"esprima";import { traverse }from"estraverse";import { EventEmitter }from"events";classRuleContextextendsEventEmitter{    report({ message }) {this.emit("report", message);    }}exportdefaultclassMyLinter{constructor() {this._emitter =new EventEmitter();this._ruleContext =new RuleContext();    }    loadRule(rule) {const ruleExports = rule.create(this._ruleContext);// on(nodeType, nodeTypeCallback);Object.keys(ruleExports).forEach(nodeType => {this._emitter.on(nodeType, ruleExports[nodeType]);        });    }    lint(code) {const messages = [];const addMessage =(message) => {            messages.push(message);        };this._ruleContext.on("report", addMessage);const ast = parse(code);        traverse(ast, {enter:(node) => {this._emitter.emit(node.type, node);            },leave:(node) => {this._emitter.emit(`${node.type}:exit`, node);            }        });this._ruleContext.removeListener("report", addMessage);return messages;    }}

このMyLinterを使って、MyLinter#loadno-console.jsを読み込ませて、

functionadd(x, y){console.log(x, y);return x + y;}add(1,3);

というコードをLintしてみます。

import assertfrom"assert";import MyLinterfrom"./MyLinter";import noConsolefrom"./no-console";const linter =new MyLinter();linter.loadRule(noConsole);const code =`function add(x, y){    console.log(x, y);    return x + y;}add(1, 3);`;const results = linter.lint(code);assert(results.length >0);assert.equal(results[0],"Unexpected console statement.");

コードにはconsoleという名前のオブジェクトが含まれているので、"Unexpected console statement." というエラーメッセージが取得できました。

RuleContext

もう一度、MyLinter.jsを見てみると、RuleContextというシンプルなクラスがあることに気づくと思います。

このRuleContextはルールから使えるユーティリティメソッドをまとめたものです。今回はRuleContext#reportというエラーメッセージをルールからMyLinterへ通知するものだけを実装しています。

ルールの実装の方を見てみると、直接オブジェクトをexportしないで、contextとしてRuleContextのインスタンスを受け取っていることが分かると思います。

module.exports = {meta: {/* ルールのメタ情報 */ },create:function (context){return {"MemberExpression":function (node){if (node.object.name ==="console") {                    context.report({                        node,message:"Unexpected console statement."                    });                }            }        };    }};

このようにして、ルールはcontext という与えられたものだけを使うので、ルールがMyLinter本体の実装の詳細を知らなくても良くなります。

どのような用途に向いている?

このプラグインアーキテクチャはPub/Subパターンを上手く使い、ESLintのように与えられたコードを読み取ってチェックするような使い方に向いています。

つまり、複数のルールで同時にLintをするというread-onlyなプラグインアーキテクチャとしてはパフォーマンスも期待できると思います。

また、ルールはcontext オブジェクトという与えられたものだけを使うようになっているため、ルールと本体が密結合にはなりにくいです。そのためcontextに何を与えるかを決めることで、ルールができる範囲を制御しやすいといえます。

どのような用途に向いていない?

逆に与えられたコード(AST)を書き換える場合には、ルールを同時に処理を行うためルール間で競合するような変更がある場合に上手く整合性を保つ必要があります。

たとえば、あるルールが書き換えるとコードの位置に更新がかかるため、その後のルールはコードの位置更新の影響を受けます。そのため、コードの書き換えをするにはこの仕組みに加えて、もう1つ抽象レイヤーを設けないと対応は難しいです。

つまり、read-writeなプラグインアーキテクチャとしては、このパターンだけでは難しい部分が出てくると思います。

ESLint 2.0からautofix、つまり書き換えの機能の導入が導入されています。ESLintでは、各ルールが書き換える位置や文字列をfixerオブジェクトという形で報告し、ESLint本体がルールの順番を考慮して最後に実際の書き換えを行うという抽象レイヤーを設けています。これはCommand パターンを利用してトランザクション的なふるまいを実現しています。

この仕組みを使っているもの

  • stylelint
    • CSSのLintするツール
  • textlint
    • テキストやMarkdownをパースしてASTにしてLintするツール

エコシステム

ESLintのルールはただのJavaScriptモジュールなので、ルール自体をnpmで公開できます。

また、ESLintはデフォルトで有効なルールはありません。そのため、利用する際は設定ファイルを作るか、sindresorhus/xoといったESLintのラッパーを利用する形となります。

ESLint公式の設定としてeslint:recommendedが用意されています。これをextendsすることで推奨の設定を継承できます。

{"extends":"eslint:recommended"}

これらの設定自体もJavaScriptで表現できるため、設定もnpmで公開して利用できるようになっています。

コーディングルールが多種多様なように、ESLintで必要なルールも個人差があると思います。設定なしで使えると一番楽ですが、設定なしだと誰でも使えるツールにするのは難しいです。それを解消するために柔軟な設定のしくみと設定を共有しやすくしています。

これはPluggable JavaScript linterを表現している仕組みといえるかもしれません。

まとめ

ここではESLintのプラグインアーキテクチャについて学びました。

  • ESLintはJavaScriptでルールを書ける
  • ASTの木構造を走査しながらPub/Subパターンでチェックする
  • ルールはcontextを受け取る以外は本体の実装の詳細を知らなくてよい
  • ルールがread-onlyだと簡単で効率的
  • read-writeとする場合は気を付ける必要がある
  • 設定をJavaScriptで表現できる
  • 設定をnpmで共有できる作りになっている

results matching ""

    No results matching ""