ESlint custom (local) rule を設定する

この記事は「[イエソド アウトプット筋 トレーニング Advent Calendar 2020](https://adventar.org/calendars/5888)」14日目の記事です。


# ESLintで独自ルールを設定する

node projectの開発時、

- プロジェクト固有でlint掛けたいなー
- でも別にpackageにするほどではないなー(リポジトリのメンテ面倒だし）

って場合に、独自ルールを追加するやり方を紹介します。


## 環境

筆者の実行環境。多少違えど問題ないと思います。

- MacOS Catalina 10.15.7
- node 12.18.4


## サンプル

何はともあれソースコードがないと話にならないと思うので、[GitHubのリポジトリ](https://github.com/swallowtail62/eslint-local-rule)に挙げておきます。

### 前準備

(git cloneするなら意味ないので飛ばしちゃってください)

```terminal
$ mkdir eslint-local-rule  // プロジェクト名(ディクレトリ名)は適当に
$ cd eslint-local-rule
$ npm init -y
$ npm i -D eslint
$ mkdir src
```

#### 依存

```json:package.json
{
  "name": "eslint-local-rule",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "eslint": "^7.15.0"
  }
}
```

### 実装

lint対象となる、以下のような`index.js`を用意します。

```javascript:src/index.js
const unused = 'unused string';
```

#### eslintの設定ファイル

[eslint公式doc](https://eslint.org/docs/user-guide/configuring#configuring-eslint)に従い、設定ファイルを追加します。
eslintの設定にはいくつか方法がありますが、ここでは`.eslintrc`を、ファイル形式は`js`を、使用します。

```javascript:.eslintrc.js
module.exports = {
  root: true,
  env: {
    node: true,
  },
  parserOptions: {
    ecmaVersion: 2020
  },
  extends: [
    'eslint:recommended'
  ],
};
```


##### parserOptions
[doc](https://eslint.org/docs/user-guide/configuring#specifying-parser-options)によれば、`ecmaVersion`のデフォルト値は`5`です。つまりes5のシンタックスしか認められません。 
そうなると`const`なんかは使用できないし、es6(2015)以降の構文が使用できないのは嫌なので、ここでは一旦最新の`2020`にします。(サンプルコードでES2020で導入されたシンタックスは出てこないので6以上であれば何でもいいです)

---

さて、ここまで設定することで、eslint実行時にerrorが吐かれるのが期待値です。

```terminal
$ npx eslint ./src

/Users/path-to-dir/eslint-local-rule/src/index.js
  1:7  error  'unused' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)
```

ではここから本題の独自ルールの設定の追加をしていきます。


### ディレクトリ作成

独自ルールを記述したファイルを配置するため、プロジェクトルートに新たにディレクトリを作成します。ここでは`eslint`という名前にします。

```terminal
$ mkdir eslint
$ mkdir eslint/rules eslint/tests
$ tree  // 一部省略
.
├── eslint
│   ├── rules
│   └── tests  // 今回は時間の都合上、test書かないです...
├── node_modules
├── package-lock.json
├── package.json
└── src
    └── index.js
```


### ルール実装

それでは、`eslint/rules`ディレクトリ内にルール用のJavaScriptファイルを実装していきます。
ESLintが使用するruleの記述には[フォーマットが定義されて](https://eslint.org/docs/developer-guide/working-with-rules#the-context-object)いて、独自ルールを定義する際は、それを参考にしていきます。

簡単のため、ここでは**特定の変数名を禁止する**というルールを実装していきます。

```javascript:eslint/rules/ban-variable-name.js
module.exports = {
  meta: {
    // 一旦後回し。空Objectで。
  },
  create(context) {
    return {
      VariableDeclarator: (node) => {
        console.log(node);
      },
    };
  }
};
```

まずは一旦、eslintと連携して動作するか検証すべく、対象nodeをコンソール出力するだけのスクリプトを書きました。(詳細な説明は後ほど。)


### ESLintと連携

さて、この状態で`eslint`を実行してももちろん、上記のスクリプトは実行されることはありません。つまり、結果は前回実行時と同じハズです。

```terminal
$ npx eslint ./src

/Users/path-to-dir/eslint-local-rule/src/index.js
  1:7  error  'unused' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)
```

`eslint`に先ほど定義したrule(`eslint/rules/ban-variable-name.js`)を実行してもらうには、以下のプラグインを利用します。

```terminal
$ npm i -D eslint-plugin-rulesdir
```

そして、`.eslintrc.js`を修正します。

```javascript:.eslintrc.js
const rulesDirPlugin = require('eslint-plugin-rulesdir');
rulesDirPlugin.RULES_DIR = 'eslint/rules';

module.exports = {
  root: true,
  env: {
    node: true,
  },
  parserOptions: {
    ecmaVersion: 2020
  },
  plugins: ['rulesdir'],
  extends: [
    'eslint:recommended'
  ],
  rules: {
    'rulesdir/ban-variable-name': 'error'
  }
};
```

`eslint-plugin-rulesdir`プラグインの利用を宣言し、追加ルールの配置されたディレクトリを指定してあげ、`rules`プロパティに利用設定を追加します。

この状態で再度`eslint`を実行すると、、
<details>
<summary>結果</summary>
<div>

```terminal
$ npx eslint ./src
Node {
  type: 'VariableDeclarator',
  start: 6,
  end: 30,
  loc: SourceLocation {
    start: Position { line: 1, column: 6 },
    end: Position { line: 1, column: 30 }
  },
  range: [ 6, 30 ],
  id: Node {
    type: 'Identifier',
    start: 6,
    end: 12,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 6, 12 ],
    name: 'unused',
    parent: [Circular]
  },
  init: Node {
    type: 'Literal',
    start: 15,
    end: 30,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 15, 30 ],
    value: 'unused string',
    raw: "'unused string'",
    parent: [Circular]
  },
  parent: Node {
    type: 'VariableDeclaration',
    start: 0,
    end: 30,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 0, 30 ],
    declarations: [ [Circular] ],
    kind: 'const',
    parent: Node {
      type: 'Program',
      start: 0,
      end: 31,
      loc: [SourceLocation],
      range: [Array],
      body: [Array],
      sourceType: 'script',
      comments: [],
      tokens: [Array],
      parent: null
    }
  }
}

/Users/rikukobayashi/private/eslint-local-rule/src/index.js
  1:7  error  'unused' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)
```
</div></details>

無事`Node`オブジェクトがコンソール出力されました :clap: 


### ルール実装に戻る

先ほどほったらかしたruleの説明に戻ります。

exportするObjectの`create`プロパティ(メソッド)で返すObjectでは、keyに`Selector`を、valueに`実行する関数`を指定します。
`Selector`というのが[公式doc](https://eslint.org/docs/developer-guide/selectors)にも述べられている通り、ASTのnodeを指定するための文字列です。CSSセレクタのように各属性を用いて指定できるようですが、大抵はnode typeを用いると思います。

`VariableDeclarator`は変数宣言の構文のtypeです。
仮に`index.js`を以下のように修正しても該当するNodeは1つしかないので、コンソール出力されるNodeオブジェクトは1つのみです。

```javascript:src/index.js
const unused = 'unused string';

function main() {
  console.log(unused);
}

main();
```

<details>
<summary>コンソール出力</summary>
<div>

```terminal
$ npx eslint ./src
Node {
  type: 'VariableDeclarator',
  start: 6,
  end: 30,
  loc: SourceLocation {
    start: Position { line: 1, column: 6 },
    end: Position { line: 1, column: 30 }
  },
  range: [ 6, 30 ],
  id: Node {
    type: 'Identifier',
    start: 6,
    end: 12,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 6, 12 ],
    name: 'unused',
    parent: [Circular]
  },
  init: Node {
    type: 'Literal',
    start: 15,
    end: 30,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 15, 30 ],
    value: 'unused string',
    raw: "'unused string'",
    parent: [Circular]
  },
  parent: Node {
    type: 'VariableDeclaration',
    start: 0,
    end: 31,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 0, 31 ],
    declarations: [ [Circular] ],
    kind: 'const',
    parent: Node {
      type: 'Program',
      start: 0,
      end: 88,
      loc: [SourceLocation],
      range: [Array],
      body: [Array],
      sourceType: 'script',
      comments: [],
      tokens: [Array],
      parent: null
    }
  }
}
```
</div>
</details>

一方で、

```javascript:src/index.js
const unused = 'unused string';

const main = () => {
  console.log(unused);
};

main();
```

のように関数宣言ではなく、関数式を用いた場合、該当Nodeは2つとなりコンソール出力は増えているでしょう。


<details>
<summary>コンソール出力</summary>
<div>

```terminal
$ npx eslint ./src
Node {
  type: 'VariableDeclarator',
  start: 6,
  end: 30,
  loc: SourceLocation {
    start: Position { line: 1, column: 6 },
    end: Position { line: 1, column: 30 }
  },
  range: [ 6, 30 ],
  id: Node {
    type: 'Identifier',
    start: 6,
    end: 12,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 6, 12 ],
    name: 'unused',
    parent: [Circular]
  },
  init: Node {
    type: 'Literal',
    start: 15,
    end: 30,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 15, 30 ],
    value: 'unused string',
    raw: "'unused string'",
    parent: [Circular]
  },
  parent: Node {
    type: 'VariableDeclaration',
    start: 0,
    end: 31,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 0, 31 ],
    declarations: [ [Circular] ],
    kind: 'const',
    parent: Node {
      type: 'Program',
      start: 0,
      end: 88,
      loc: [SourceLocation],
      range: [Array],
      body: [Array],
      sourceType: 'script',
      comments: [],
      tokens: [Array],
      parent: null
    }
  }
}
Node {
  type: 'VariableDeclarator',
  start: 39,
  end: 78,
  loc: SourceLocation {
    start: Position { line: 3, column: 6 },
    end: Position { line: 5, column: 1 }
  },
  range: [ 39, 78 ],
  id: Node {
    type: 'Identifier',
    start: 39,
    end: 43,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 39, 43 ],
    name: 'main',
    parent: [Circular]
  },
  init: Node {
    type: 'ArrowFunctionExpression',
    start: 46,
    end: 78,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 46, 78 ],
    id: null,
    expression: false,
    generator: false,
    async: false,
    params: [],
    body: Node {
      type: 'BlockStatement',
      start: 52,
      end: 78,
      loc: [SourceLocation],
      range: [Array],
      body: [Array],
      parent: [Circular]
    },
    parent: [Circular]
  },
  parent: Node {
    type: 'VariableDeclaration',
    start: 33,
    end: 78,
    loc: SourceLocation { start: [Position], end: [Position] },
    range: [ 33, 78 ],
    declarations: [ [Circular] ],
    kind: 'const',
    parent: Node {
      type: 'Program',
      start: 0,
      end: 88,
      loc: [SourceLocation],
      range: [Array],
      body: [Array],
      sourceType: 'script',
      comments: [],
      tokens: [Array],
      parent: null
    }
  }
}
```
</div>
</details>

---

さて、先ほど提起した**特定の変数名を禁止する**というルールですが、例えば具体的に`hoge`という言葉を禁止するとします。
その場合は以下のようなruleになるでしょう。

```javascript:eslint/rules/ban-variable-name.js
module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Ban some keywords from being used as a variable name",
      category: "Variables",
    },
    schema: [], // no options
  },
  create(context) {
    return {
      VariableDeclarator: (node) => {
        if (node.id.name === "hoge") {
          context.report({
            node,
            message: `The keyword ${node.id.name} is banned to be used as a variable name`,
          });
        }
      },
    };
  },
};
```

`index.js`を修正して

```javascript:src/index.js
const unused = 'unused string';

const hoge = () => {
  console.log(unused);
}

hoge();
```

eslintを実行すると

```terminal
$ npx eslint ./src

/Users/path-to-dir/eslint-local-rule/src/index.js
  3:7  error  The keyword hoge is banned to be used as a variable name  rulesdir/ban-variable-name

✖ 1 problem (1 error, 0 warnings)
```

無事怒られました :clap: 

ただ、今のまま`hoge`というキーワードのみに固定されるのは芸がないので、eslintの設定オプションで指定できるようにしましょう。

```javascript:eslint/rules/ban-variable-name.js
module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Ban some keywords from being used as a variable name",
      category: "Variables",
    },
    schema: [
      {
        type: "object",
        properties: {
          words: {
            type: "array",
            items: {
              type: "string",
            },
          },
        },
        additionalProperties: false,
      },
    ],
  },
  create(context) {
    const [option] = context.options;
    const bannedWords = option ? option.words : [];
    return {
      VariableDeclarator: (node) => {
        if (bannedWords.includes(node.id.name)) {
          context.report({
            node,
            message: `The keyword ${node.id.name} is banned to be used as a variable name`,
          });
        }
      },
    };
  },
};
```

#### `schema`プロパティ

[doc](https://eslint.org/docs/developer-guide/working-with-rules#options-schemas)にある通り、ruleのoptionに制約を付与することができます。[json-shema](https://json-schema.org/understanding-json-schema/index.html)で定義されているプロパティなら何でも使えるので、好きに定義できます。

ここでは、`words`プロパティ名を持つObjectをオプションに指定したので、利用(設定)する場合はこんな感じになります。

```javascript:.eslintrc.js
module.exports = {
  ...省略,
  rules: {
    'rulesdir/ban-variable-name': ['error', { words: ['hoge'] }]
  }
};
```


この設定でeslintを実行してみると

```terminal
$ npx eslint ./src

/Users/path-to-dir/eslint-local-rule/src/index.js
  3:7  error  The keyword hoge is banned to be used as a variable name  rulesdir/ban-variable-name

✖ 1 problem (1 error, 0 warnings)
```

OK :clap: 

ついでに試しにちょっと修正してみます。

```src/index.js
const unused = 'unused string';

const hoge = () => {
  console.log(unused);
}

hoge();

const fuga = 100;
```

```javascript:.eslintrc.js
module.exports = {
  ...省略,
  rules: {
    'rulesdir/ban-variable-name': ['error', { words: ['hoge', 'fuga'] }]
  }
};
```

これで再度実行してみると、、

```terminal
$ npx eslint ./src

/Users/path-to-dir/eslint-local-rule/src/index.js
  3:7  error  The keyword hoge is banned to be used as a variable name  rulesdir/ban-variable-name
  9:7  error  The keyword fuga is banned to be used as a variable name  rulesdir/ban-variable-name
  9:7  error  'fuga' is assigned a value but never used                 no-unused-vars

✖ 3 problems (3 errors, 0 warnings)
```

ちゃんと正しくオプションが反映されていますね。めでたし :clap: 


## VSCodeとの連携

VSCode user向けの話になります。

上記までで、独自ルールをESLint cliと連携させることはできました。
普段、VSCodeで開発していてeslintを使用している大抵の人は、extensionを使ってリアルタイム(リアクティブ?)でeslintの警告が見れるようにしていると思います。
上記で追加した独自ルールも同様に、(vscodeの設定に手を加えることなく)警告が見れるようになります。

警告が表示されない場合は**VSCodeを再起動**してみてください。改めてeslint extensionが設定ファイルを読み込んで表示してくれるようになるハズです。



## おわり

こんな感じで簡単にカスタムルールを追加できるよっていうのをお話しました。

本来であればちゃんとtestを書きましょうってところで、eslintが提供してくれている`RuleTester`を使ったテストのことまで書こうと思いましたが、ちょっと時間的に厳しいのでまたの機会に。。