ESLint
この文章はESLint 1.3.0を元に書かれています。
ESLintはJavaScriptのコードをJavaScriptで書かれたルールによって検証するLintツールです。
大まかな動作としては、検証したいJavaScriptのコードをパースしてできたAST(抽象構文木)をルールで検証し、エラーや警告を出力します。
このルールがプラグインとして書くことができ、ESLintのすべてのルールはプラグインとして実装されています。
The pluggable linting utility for JavaScript and JSX
ESLintサイト上には、上記のように書かれていることからもわかりますが、プラグインに重きを置いた設計となっています。
今回はESLintのプラグインアーキテクチャがどうなっているかを見て行きましょう。
どう書ける?
ESLintでは.eslintrc
という設定ファイルに利用するルールの設定をして使うため、実行方法についてはドキュメントを参照してください。
ESLintにおけるルールとは、次のような関数をexportしたモジュールです。関数にはcontext
オブジェクトが渡されるので、それに対して1つのオブジェクトを返すようにします。
"use strict";
module.exports = function (context) {
return {
"MemberExpression": function (node) {
if (node.object.name === "console") {
context.report(node, "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
というオブジェクトはただのユーティリティ関数と考えて問題ありません。ルールの本体は関数が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の探索がイメージしにくい場合は次のルールで探索の動作を見てみると分かりやすいかもしれません。
function debug(string){
console.log(string);
}
debug("Hello");
その他、ESLintのルールの書き方についてはドキュメントや次の記事を見てみるといいでしょう。
どのような仕組み?
ESLintはコードをパースしてASTにして、そのASTをJavaScriptで書いたルールを使いチェックするという大まかな仕組みは分かりました。
次に、このルールをプラグインとする仕組みがどのように動いているのか見て行きましょう。
ESLintのLintは次のような3つの手順で行われています。
- ルール毎に使っている
Node.type
をイベント登録する - ASTをtraverseしながら、
Node.type
のイベントを発火する - ルールから
context.report()
された内容を集めて表示する
このイベントの登録と発火にはEventEmitterを使い、 ESLint本体に対してルールは複数あるので、典型的なPub/Subパターンとなっています。
擬似的なコードで表現すると次のような流れでLintの処理が行われています。
import {parse} from "esprima";
import {traverse} from "estraverse";
import {EventEmitter} from "events";
function lint(code){
// コードをパースしてASTにする
let ast = parse(code);
// イベントの登録場所
let emitter = new EventEmitter();
let results = [];
emitter.on("report", message => {
// 3. のためのreportされた内容を集める
results.push(message);
});
// 利用するルール一覧
let ruleList = getAllRules();
// 1. ルール毎に使っている`Node.type`をイベント登録する
ruleList.forEach(rule => {
// それぞれのルールに定義されているメソッド一覧を取得
// e.g) MemberExpression(node){}
// => {"MemberExpression" : function(node){}, ... } というオブジェクト
let methodObject = getDefinedMethod(rule);
Object.keys(methodObject).forEach(nodeType => {
emitter.on(nodeType, methodList[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
- 利用するルールを登録する処理
rule
はno-console.jsがexportしたもの
MyLinter#lint(code): string[]
code
を受け取りルールによってLintした結果を返す- Lint結果はエラーメッセージの配列とする
実装したものが次のようになっています。
"use strict";
import {parse} from "esprima";
import {traverse} from "estraverse";
import {EventEmitter} from "events";
class RuleContext extends EventEmitter {
report(node, message) {
this.emit("report", message);
}
}
export default class MyLinter {
constructor() {
this._emitter = new EventEmitter();
this._ruleContext = new RuleContext();
}
loadRule(rule) {
let ruleExports = rule(this._ruleContext);
// on(nodeType, nodeTypeCallback);
Object.keys(ruleExports).forEach(nodeType => {
this._emitter.on(nodeType, ruleExports[nodeType]);
});
}
lint(code) {
let messages = [];
let addMessage = (message)=> {
messages.push(message);
};
this._ruleContext.on("report", addMessage);
let 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#load
でno-console.jsを読み込ませて、
function add(x, y){
console.log(x, y);
return x + y;
}
add(1, 3);
というコードをLintしてみます。
"use strict";
import assert from "assert";
import MyLinter from "./MyLinter";
import noConsole from "./no-console";
let linter = new MyLinter();
linter.loadRule(noConsole);
const code = `
function add(x, y){
console.log(x, y);
return x + y;
}
add(1, 3);
`;
let 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
のインスタンスを受け取っていることが分かると思います。
"use strict";
module.exports = function (context) {
return {
"MemberExpression": function (node) {
if (node.object.name === "console") {
context.report(node, "Unexpected console statement.");
}
}
};
};
このようにして、ルールは context
という与えられたものだけを使うので、ルールがMyLinter本体の実装の詳細を知らなくても良くなります。
どのような用途に向いている?
このプラグインアーキテクチャはPub/Subパターンを上手く使い、 ESLintのように与えられたコードを読み取ってチェックするような使い方に向いています。
つまり、read-onlyなプラグインアーキテクチャとしてはパフォーマンスも期待できると思います。
また、ルールは context
という与えられたものだけを使うようになっているため、ルールと本体が密結合にはなりにくいです。そのためcontext
に何を与えるかを決めることで、ルールが行える範囲を制御しやすいといえます。
どのような用途に向いていない?
逆に与えられたコード(AST)を書き換える場合には、ルールを同時に処理を行うためルール間で競合するような変更がある場合に破綻してしまいます。
そのため、この仕組みに加えてもう1つ抽象レイヤーを設けないと対応は難しいです。
つまり、read-writeなプラグインアーキテクチャとしては単純にこのパターンだけでは難しい部分が出てくると思います。
ESLint 2.0でautofixing、つまり書き換えの機能の導入が予定されています。 これはルールからの書き換えのコマンドを
SourceCode
というオブジェクトに集約して、最後に実際の書き換えを行うという抽象レイヤーを設けています。
この仕組みを使っているもの
- azu/textlint
- テキストやMarkdownをパースしてASTにしてLintするツール
エコシステム
ESLintのルールはただのJavaScriptモジュールなので、ルール自体をnpmで公開できます。
また、ESLintはデフォルトで有効なルールはありません。そのため、利用する際は設定ファイルを作るか、sindresorhus/xoといったESLintのラッパーを利用する形となります。
ESLint公式の設定としてeslint:recommended
が用意されています。これをextends
することで推奨の設定を継承できます。
{
"extends": "eslint:recommended"
}
これらの設定自体もJavaScriptで表現できるため、設定もnpmで公開して利用できるようになっています。
コーディングルールが多種多様なように、ESLintで必要なルールも個人差があると思います。設定なしで使えると一番楽ですが、設定なしだと誰でも使えるツールにするのは難しいです。それを解消するために柔軟な設定のしくみと設定を共有しやすくしています。
これはThe pluggable linting utilityを表現している仕組みといえるかもしれません。
まとめ
ここではESLintのプラグインアーキテクチャについて学びました。
- ESLintはJavaScriptでルールを書ける
- ASTの木構造を走査しながらPub/Subパターンでチェックする
- ルールは
context
を受け取る以外は本体の実装の詳細を知らなくてよい - ルールがread-onlyだと簡単で効率的
- read-writeとする場合は気を付ける必要がある
- 設定をJavaScriptで表現できる
- 設定をnpmで共有できる作りになっている