ESLint ルールを TypeScript で書く

備忘録。ESLint のルールを含むプラグインを TypeScript で書く時のポイントについて。

プラグインの仕組み自体は公式ドキュメントに従いつつ、TypeScript でどう対応させるかについて述べる。

依存パッケージ

最低限、以下を入れる:

  • dependencies
    • eslint
  • devDependencies
    • typescript
    • @types/eslint
    • @types/estree

今回はテストランナーに Jest を使うため、以下も devDependencies に追加する:

  • jest
  • ts-jest

@types/jest は不要であることに注意(理由は後述)。

なお tsconfig.json 及び jest.config.[tj]s の内容は通常のプロジェクトと比べて特に違いはないので、説明は省略する。

ルール定義

ここでは文字列リテラル、もしくはテンプレートリテラルの文字列片が hello である場合に警告するルール no-hello を題材とする。

no-hello.ts

import { Rule } from "eslint"
import { Literal, TemplateElement } from "estree"

export const noHello: Rule.RuleModule = {
  meta: {
    type: "problem"
  },
  create(context: Rule.RuleContext): Rule.RuleListener {
    return {
      Literal(node: Literal & Rule.NodeParentExtension) {
        if (node.value === "hello") {
          context.report({ node, message: '"hello" is not allowed' })
        }
      },
      TemplateElement(node: TemplateElement & Rule.NodeParentExtension) {
        if (node.value.cooked === "hello") {
          context.report({ node, message: '"hello" is not allowed' })
        }
      },
    }
  }
}

各ルールの定義の型 eslint.Rule.RuleModulecreate 関数が受け取るコンテキストの型 eslint.Rule.RuleContext となる。

create が返す visitor の各関数の引数は、estree の対応するノードの型と、親ノードを参照できるようするための eslint.Rule.NodeParentExtension の交差型となる(この交差型によって親ノードを .parent で参照することができる)。

プラグイン定義

index.ts

import { noHello } from "./no-hello"

export = {
  rules: {
    'no-hello': noHello
  },
}

プラグイン定義を表す型は現時点ではないようなので、プラグインのドキュメントを見ながら typo しないように実装する。

tsconfig.jsonmodule"commonjs" なら、 module.exports = の代わりに export = が使える。

ルールのテスト

前述の通りテストランナーには Jest を使う。が、Jest の describe/itexpect は使わない(これが @types/jest が不要な理由)。ESLint がルールのテスト用に mocha をベースにしたテストユーティリティを提供している。テストはそのユーティリティを使って記述し、Jest はエントリポイントとしてのみ使う。((このテストのやり方は typescript-eslint を参考したものである。))

no-hello.test.ts

import { noHello } from "../src/no-hello"
import { RuleTester } from "eslint"

const ruleTester = new RuleTester({
  parserOptions: {
    ecmaVersion: 2015
  }
})

ruleTester.run("no-hello", noHello, {
  valid: [
    { code: 'const x = "goodbye"' },
    { code: 'const x = `goodbye`' },
  ],
  invalid: [
    { code: 'const x = "hello"', errors: [{ message: '"hello" is not allowed' }] },
    { code: 'const x = `hello${process.env.MESSAGE}`', errors: [{ message: '"hello" is not allowed' }] },
  ]
})

まず eslint.RuleTesterインスタンスを作成する。コンストラクタの parserOptions には、.eslintrcparserOptions と同様に内部で使うパーサが受け取るオプションを渡すことができる。今回はテストコード内で const を使いたいため、ecmaVersion: 2015 を渡している。

デフォルトのパーサは espree である。パーサを変えたい時は parser にパーサのパスを設定する(これも .eslintrcparser と同様の形式)。

RuleTester.run() にルール名とルール定義のオブジェクト(noHello)、そして valid/invalid それぞれのケースを渡すことでテストが実行される。上記の例ではエラーメッセージのみをチェックしているが、追加でエラーとなるべき箇所をアサートすることもできる。詳細は RuleTester.ValidTestCaseRuleTester.InvalidTestCase の型定義を確認すること。

まとめ

ESLint のルール・プラグインを TypeScript で実装 & テストする方法についてまとめた。

注意点として @types/eslint の型定義では estree を拡張した AST を扱えない(eslint.Rule.RuleListener 参照)。その場合、型定義を置き換えるなり any でごまかすなりの工夫が必要になる。