こんにちは。
アメーバピグでNode.jsを使って開発をしている中村と申します。
平日はエンジニア、土日は主夫として働いています。
さて、早速ですが、この記事ではESLintを使って、JavaScriptのソースコードのバグを発見する手順をご紹介したいと思います。
ESLintとは
ESLintはNicholas C. Zakas氏が中心となって開発しているJavaScriptのLintツールです。JavaScriptのLintツールといえば、最近ではJSHintが定番だと思います。
ESLintはJSHint同等の機能を持つ他、解析ルールが完全にプラガブルになっており、独自ルールを自由に追加できるという特徴があります。
例えば、JSHintでいうところの、strict(strict modeで実行されるかをチェック)というオプションは下記のURLのように個別のルールとして実装されています。
https://github.com/eslint/eslint/blob/master/lib/rules/strict.js
ESLintを使ってみる
では、次に基本的な使い方を説明したいと思います。インストールはnpmで一発です。
$ npm i -g eslint
ESLintでは.eslintrcというファイルでルールのON/OFFを設定します。
.eslintrcは下記のようなJSON形式で記述します。
{
// コメントも可能
"env": {
"node": true, // Node.jsで実行された場合のグローバル変数追加&ルール適用
"mocha": true, // mochaで実行された場合のグローバル変数追加
},
"rules": {
"strict": 1 // 0: off, 1: warn(exit codeに影響なし), 2: error
}
}
// コメントも可能
"env": {
"node": true, // Node.jsで実行された場合のグローバル変数追加&ルール適用
"mocha": true, // mochaで実行された場合のグローバル変数追加
},
"rules": {
"strict": 1 // 0: off, 1: warn(exit codeに影響なし), 2: error
}
}
.eslintrcをプロジェクトのルートディレクトリに配置し、下記のように実行します。
$ cd project_root # プロジェクトのルートディレクトリに移動
$ eslint test.js
test.js
1:0 warning Missing "use strict" statement strict
✖ 1 problem
設定したルールに違反している場合、警告が表示されます。$ eslint test.js
test.js
1:0 warning Missing "use strict" statement strict
✖ 1 problem
ESLintに独自ルールを追加する
では、基本的な使い方を学んだところで、次にESLintに独自ルールを定義する流れを見ていきたいと思います。ESLintの仕組み
まず、ESLintの仕組みを説明します。ESLint内部で行われている処理は大まかにはこの図のような流れになります。
1. JSファイルからソースコードを文字列として読み込む
2. ソースコードをEsprimaに渡し、AST(Abstract Syntax Tree)に変換
3. ASTを個別ルールでチェック
要は、ESLintがASTを個別ルールのプログラムに渡してくれるので、ASTをチェックする3の部分だけ実装すればよいということになります。
ここで、ASTという言葉が出てきましたが、コードをJSONで表現したものというくらいで理解していただければと思います。
例えば、
var hoge = 1;
というソースコードはEsprimaでパースすると
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "hoge"
},
"init": {
"type": "Literal",
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
]
}
のようなJSONに変換されます。"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "hoge"
},
"init": {
"type": "Literal",
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
]
}
なお、Esprimaのデモでブラウザ上でコードからASTへのパースを試せるので、触ってみると理解が深まると思います。
※メモ:Esprimaがパースした結果のASTはMozillaのSpiderMonkey ParseAPIの仕様に基づきます。より理解を深めたい方はドキュメントを読んでみてください。
発見したいバグ
続いて、今回発見したいバグについて説明します。Node.jsでcallbackの第一引数のエラーの有無でエラーハンドリングするのはベーシックな方法ですが、その際、下記のようなバグのあるコードを書いてしまう可能性があります。
function write(filename, data, callback) {
fs.writeFile(filename, data, function(err) {
if (err) {
callback(err);
// returnを忘れた。。
// これではcallbackが二回実行されてしまう。。
}
callback();
});
}
fs.writeFile(filename, data, function(err) {
if (err) {
callback(err);
// returnを忘れた。。
// これではcallbackが二回実行されてしまう。。
}
callback();
});
}
コイツを見つけてみたいと思います。
実装手順
eslint-testerというESLintのテスト向けユーティリティが提供されているのでこれを利用しながら開発するのがよいかと思います。テストフレームワークとしてはmochaを使う想定です。1. ルールファイル(lib/rules/no-callback-return.js)を書く。
2. .eslintrcを書く。(ファイル名から.jsを取り除いた名前がルールのIDになります。)
{
"env": {
"mocha": true,
"node": true
},
"rules": {
"no-callback-return": 2,
}
}
"env": {
"mocha": true,
"node": true
},
"rules": {
"no-callback-return": 2,
}
}
3.テストファイル(tests/lib/rules/no-callback-return.js)を作成。validの配列に正しいコード、invalidの配列に不正なコードを足していく。(なお、ソースコードを文字列では書くのは辛いので、heredocという関数を定義して、ソースコードを書きやすくしてみました。)
var eslintTester = require('eslint-tester');
function heredoc(func) {
return func
.toString()
.split('\n')
.slice(1, -1)
.join('\n');
}
eslintTester.addRuleTest('lib/rules/no-callback-return', {
valid: [
{
code: heredoc(function() {/*
function test(err, callback) {
if (err) {
callback(err);
return;
}
callback();
}
*/}),
},
],
invalid: [
{
code: heredoc(function() {/*
function test(err, callback) {
if (err) {
callback(err);
}
callback();
}
*/}),
errors: 1,
},
]
});
function heredoc(func) {
return func
.toString()
.split('\n')
.slice(1, -1)
.join('\n');
}
eslintTester.addRuleTest('lib/rules/no-callback-return', {
valid: [
{
code: heredoc(function() {/*
function test(err, callback) {
if (err) {
callback(err);
return;
}
callback();
}
*/}),
},
],
invalid: [
{
code: heredoc(function() {/*
function test(err, callback) {
if (err) {
callback(err);
}
callback();
}
*/}),
errors: 1,
},
]
});
4. テスト実行→コード修正・・・を繰り返す。
$ mocha tests/lib/rules/no-callback-return.js
※メモ:ルールの書き方で困ったときにはESLint 標準のルールの実装が参考になるので、目を通してみるとよいと思います。
コード
上記のバグのあるソースコードを発見するルールはこんな感じになりました。var estraverse = require('estraverse');
module.exports = function(context) {
"use strict";
var ifStack = [];
var callbackRe = /callback|^(?:next|cb|done)/i;
function checkCallback(node) {
// callbackっぽいか
if (!callbackRe.test(node.callee.name)) {
return;
}
// ifブロック内ではない
var ifNode = ifStack.length && ifStack[ifStack.length - 1];
if (!ifNode) {
return;
}
// elseあり
if (ifNode.alternate) {
return;
}
// if (callback) { callback(); } のパターン
var isCallbackTest = ifNode.test.type === 'Identifier' &&
callbackRe.test(ifNode.test.name);
if (isCallbackTest) {
return;
}
var returnExists = false;
estraverse.traverse(ifNode, {
enter: function(node) {
if (node.type === 'ReturnStatement') {
returnExists = true;
this.break();
return;
}
if (node.type === 'FunctionStatement' || node.type === 'FunctionDeclaration') {
this.skip();
return;
}
},
});
if (returnExists) {
return;
}
context.report(node, 'no callback return!');
}
function startIf(node) {
ifStack.push(node);
}
function endIf() {
ifStack.pop();
}
return {
"CallExpression": checkCallback,
"IfStatement": startIf,
"IfStatement:exit": endIf,
};
};
module.exports = function(context) {
"use strict";
var ifStack = [];
var callbackRe = /callback|^(?:next|cb|done)/i;
function checkCallback(node) {
// callbackっぽいか
if (!callbackRe.test(node.callee.name)) {
return;
}
// ifブロック内ではない
var ifNode = ifStack.length && ifStack[ifStack.length - 1];
if (!ifNode) {
return;
}
// elseあり
if (ifNode.alternate) {
return;
}
// if (callback) { callback(); } のパターン
var isCallbackTest = ifNode.test.type === 'Identifier' &&
callbackRe.test(ifNode.test.name);
if (isCallbackTest) {
return;
}
var returnExists = false;
estraverse.traverse(ifNode, {
enter: function(node) {
if (node.type === 'ReturnStatement') {
returnExists = true;
this.break();
return;
}
if (node.type === 'FunctionStatement' || node.type === 'FunctionDeclaration') {
this.skip();
return;
}
},
});
if (returnExists) {
return;
}
context.report(node, 'no callback return!');
}
function startIf(node) {
ifStack.push(node);
}
function endIf() {
ifStack.pop();
}
return {
"CallExpression": checkCallback,
"IfStatement": startIf,
"IfStatement:exit": endIf,
};
};
実行
それでは実行してみます。
$ eslint --reset --config ./.eslintrc --rulesdir lib/rules/ test.js
test.js
4:12 error no callback return! no-callback-return
✖ 1 problem
バグが見つかりました!test.js
4:12 error no callback return! no-callback-return
✖ 1 problem
なお、各オプションの意味は下記の通りです。
--reset 全てのオプションをOFFにする
--config 指定した設定ファイルを使用する
--rulesdir 独自ルールを定義したファイルのあるディレクトリを指定する
--config 指定した設定ファイルを使用する
--rulesdir 独自ルールを定義したファイルのあるディレクトリを指定する
上記のサンプルコード一式はhttps://github.com/yukidarake/eslint-exampleに上げました。興味のある方はご覧ください。
まとめ
以上、ざっとですが、ESLintで独自ルールを追加する手順を見ていきました。ESLintは以前は解析速度がJSHintと比較してかなり遅いなど、実戦投入するにはちょっとためらう面もありました。しかしながら、現在では、速度の問題も解消され、実用フェーズに入ってきた感があります。JSHintを使っているプロジェクトでも、足りないチェック機能をESLintで追加していく形で導入するのはありかなと思います。
それでは最後まで読んでいただき、ありがとうございました!
この記事が少しでも皆様のお役に立てば幸いです。