😸

自分で ESLint のルールを作ってみた件

2022/02/18に公開

PaletteWorks Editor の開発中、あるバグを修正しようとして、いろいろコード読むわけじゃないですか……それで五回くらい読んでも見逃しちゃったんですよね…… concat() という邪悪なものを
問題箇所
意識してみるとそうでもないけど、意識せずに見ると全然見逃しちゃうんですよね……なんか、filter とか map とか forEach の類に脳が分類しちゃって、気づけなくなってる
だからどうせ [...a, ...b] という文法があるし、そっち使ったら全然わかりやすいし、実際開発途中で段々意識せずにそれにリファクターした部分もあります。

リファクターしたものがこうなります。まぁ大部分かりやすくなったのではないでしょうか。

目標

ということで、実際に ESlint のルールを作って、concat を消滅させよう!

TL;DR

レポです。(Initial Commit に変更が集中したのは、いろいろ弄りまくっても上手く行かず、やっと成功した時に遂に git したというのが理由です)
https://github.com/mkpoli/eslint-plugin-no-array-concat

NPM です
https://www.npmjs.com/package/eslint-plugin-no-array-concat

前提知識

ESLint とは

Javascript の検証ツールです。時に書き間違いがあっても、コンパイルに怒られない場合もあるし、コンパイルする前に(書きながらとか)気付きたいし、そのために、コンパイルとは別に、コードの正確性を検証するために利用されているツールになります。

普通のコンパイラーのエラーや、シンタックスのエラーよりも細かて厳しいものが多いけど、ESLint ではそれのカスタマイズができて便利ですね

AST とは

抽象構文木(Abstrat Syntax Tree)のことで、コンパイラーとかパーサーが、人が書いたコードを読み取るのに、いろいろ余計なものを取り除いたり、文法だけを読み取ってきたものがそれで、ESLint がパーサーによって作られた AST を元にコードをルールに通して検証している

実際 Typescript や Babel などのトランスパイラーも AST を生成してやっているわけです

ToDo

する必要のあること

  • eslint のプラグイン準拠
  • npm にリリースする準備をする

した方がいいこと

  • Typescript で書いてビルドしてからリリースする
  • Jest でテストを行う
  • その他諸々

インフラ

まず、Typescript + Jest などでインフラを色々準備しておきます。

pnpm i -D typescript @types/node
pnpm i -D jest @types/jest ts-jest
pnpm i -D npm-run-all rimraf np

(np の開発が停滞しているし、そしてなぜか最近上手く動かないっぽいので、release-it とか publish-please とか semantic-release とかへの移行を考えているけど、まぁ np を他のものを変えるだけなので、この例ではそのままにする)

package.json
{
  "name": "eslint-plugin-no-array-concat",
  "version": "0.0.0",
  "description": "ESLint rule for not using Array.prototype.concat()",
  "scripts": {
    "clean": "rimraf dist",
    "build:typescript": "tsc",
    "build": "run-s clean build:typescript",
    "test": "jest",
    "publish": "np",
    "release": "run-s clean build test publish"
  },
}
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2015",
    "module": "CommonJS",
    "rootDir": "./src",
    "outDir": "./dist",
    "types": ["node"],
    "typeRoots": [
      "./"
    ]
  },
  "include": [
    "src",
    "global.d.ts",
  ],
  "exclude": ["node_modules", "tests"]
}
tsconfig.test.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": true,
  },
  "files": [
    "estree.ts"
  ],
  "include": [
    "tests/**/*.ts",
    "global.d.ts"
  ]
}
jest.config.js
module.exports = {
  preset: 'ts-jest',
  testMatch: ['**/tests/**/*.ts'],
  globals: {
    'ts-jest': {
      tsconfig: './tsconfig.test.json'
    },
  },
}
node_modules/
dist/

フォルダ構成が以下になります。

ドキュメントを書く

まず軽くドキュメントを書いて整理しましょう。docs/rules/no-array-concat.md
https://github.com/mkpoli/eslint-no-array-concat/blob/master/docs/rules/no-array-concat.md

簡単に契機・目的、通るものと通らないもの、使わない時は?、リソース・リンクなど、まぁ公式のドキュメント構成を参照すればいいと思います。
https://eslint.org/docs/rules/

これでわかったのは、やるべきことが、Array のインスタンスが concat をコールした時にだけエラーを出せばいいことがわかります。

問題発生

しかし、ここで問題が起こります。Array のインスタンス、つまり、任意の Array タイプのもの、その中には

  • Arrayリテラル [1, 2, 3]
  • Arrayの変数 let a = [1, 2, 3]; a
  • Arrayをリターンするもの [1.2.3].map((x) => x + 1)
    などがあります。

リテラルの時はまだよくて、すぐ Array だと気付くから、そして Array をリターンするものの一部ならまだできる。しかし、Array の変数は何でもなり得ます。そうすると、Array 以外の他の物の concat と区別がつかなくなります。そして、例えば Buffer など、concat しかできないものや、Array 以外のところで、concat と命名したクラスメソッドなど仕方ないものもあります。

ESLint には、変数の型を判定できる機能が付いていない、例えば公式の array-callback-return というルールでも、単に名前から mapfiltersome を判定していて、Array ではないものまで影響がでてしまいます。これもこのルールが recommend になっていない理由かもしれません。
https://eslint.org/docs/rules/array-callback-return

どうしよう……

解決へ

Javascript と型と言えば、何が脳裏に浮かぶでしょうか。そう、Typescript です!Typescript には型の情報が含まれています。それを利用すれば、以上のことが解決できるのではないでしょうか。

ということで、Typescript 関連のものはどうやって lint されていたのでしょう。実は、Typescript ESLint という大変ありがたいものがあります。実際、Typescript のコードもこれに掛かれば、簡単に検証できます。
https://github.com/typescript-eslint/typescript-eslint

ESLint には、プラグイン(ルールやコンフィグ・特殊ファイルへの処理)の他に、パーサーを指定することもできます。Typescript ESLint には、Typescript (.ts) ファイルを解析するのに、プラグインの@typescript-eslint/eslint-pluginとパーサーの@typescript-eslint/parserの両方を指定することができます。

さらにすごいのは、@typescript-eslint/parser は、Typescript だけじゃなく、Javascript ファイルも検査できるため、@typescript-eslint/parser に基づいて書かれたプラグインは、少しの設定(.tsconfig / .eslintrc)だけで、Javascript のプロジェクトを lint することもできます

実際に作成してみる

インストール

pnpm i -D @typescript-eslint/parser
pnpm install @typescript-eslint/utils eslint-ast-utils

準備する


以上のフォルダ構成になるようにファイルを作り、ヘルパーモジュールを書きます。

src/utils.ts
import * as path from 'path'
import { ESLintUtils } from '@typescript-eslint/utils'

export const createRule = ESLintUtils.RuleCreator(name => {
  const dirname = path.relative(__dirname, path.dirname(name))
  const basename = path.basename(name, path.extname(name))
  return `https://github.com/mkpoli/eslint-plugin-no-array-concat/blob/master/docs/${dirname}/${basename}.md`
})
src/index.ts
module.exports = {
  rules: {
    'no-array-concat': require('./rules/no-array-concat'),
  }
}

テストケースを書く

https://typescript-eslint.io/docs/development/custom-rules/#testing-typed-rules

型検査機能が使われる場合、テストを行うには実際に .tsconfig.json が必要で、そのために RuleTester で設定しなければならない。内容は特になんでもいいですが、filesestree.ts を含めないとエラーが出ます。

tests\rules\no-array-concat.ts
import rule from '../../src/rules/no-array-concat';
import { TSESLint } from '@typescript-eslint/utils'

export const ruleTester = new TSESLint.RuleTester({
  parser: require.resolve('@typescript-eslint/parser'),
  parserOptions: {
    ecmaVersion: "latest",
    sourceType: "module",
    project: "./tsconfig.test.json"
  }
})

ruleTester.run("no-array-concat", rule, {
  valid: [
    {
      code: "[...a, ...b]", parserOptions: { ecmaVersion: 6 }
    },
    "Array.concat()",
    {
      code: "foo.concat(bar)",
    },
    "new CustomClass().concat()",
  ],

  invalid: [
    {
      code: "[1, 2, 3].concat([4, 5, 6])",
      errors: [{ messageId: "noArrayConcat" }],
    },
    {
      code: "var foo = [1, 2, 3]; foo.concat([4, 5, 6])",
      errors: [{ messageId: "noArrayConcat" }],
    },
    {
      code: "var foo = new Array(3); foo.concat([4, 5, 6])",
      errors: [{ messageId: "noArrayConcat" }],
    },
    {
      code: "var foo = [0, 1, 2].map(x => x + 1); foo.concat([4, 5, 6])",
      errors: [{ messageId: "noArrayConcat" }],
    }
  ],
});

ルールを書く

ということで、実際にルールを書いていきましょう。

no-array-concat.ts
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'
import { ESLintUtils } from '@typescript-eslint/utils'

import { getPropertyName } from 'eslint-ast-utils'
import { createRule } from '../utils';

export = createRule({
  name: __filename,
  meta: {
    type: "suggestion",
    docs: {
      description: "Prevent using Array.prototype.concat() for Array concatenation",
      recommended: false,
    },
    schema: [], // Add a schema if the rule has options
    fixable: 'code',
    messages: {
      noArrayConcat: "Do not use Array.prototype.concat(...)",
    }
  },
  defaultOptions: [],
  create(context) {
    const parserServices = ESLintUtils.getParserServices(context);
    const checker = parserServices?.program?.getTypeChecker() as any;

    if (!checker || !parserServices) {
      throw Error(
        "Types not available, maybe you need set the parser to @typescript-eslint/parser"
      )
    }

    function disallowedConcat(node: TSESTree.CallExpression) {
      if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
        return;
      }

      const callee = node.callee as TSESTree.MemberExpression

      const propertyName = getPropertyName(callee)
      if (!propertyName || propertyName !== 'concat') {
        return;
      }

      const tsNodeMap = parserServices.esTreeNodeToTSNodeMap.get(node.callee.object)
      const type = checker!.getTypeAtLocation(tsNodeMap)
      if (!checker.isArrayType(type)) {
        return;
      }

      context.report({
        messageId: "noArrayConcat",
        loc: callee.property.loc,
        node
      })
    }

    return {
      CallExpression: disallowedConcat
    }
  }
});

create(context) 函数

まず、メタデータの設定は省くことにして、create 関数は実際に検証のロジックが書かれているところです。そこを見ていきましょう

const parserServices = ESLintUtils.getParserServices(context);
const checker = parserServices?.program?.getTypeChecker() as any;

一番上の、これは、@typescript-eslint/parser の魔法で、これを使うと、Typescript 内部の型検査機構を得ることができます。それでこれから型のチェックを行います。

そして、create の戻り値に注目してみます。 CallExpression: は AST の中のすべての CallExpression、つまりすべての関数呼び出しが行われている木のノードにこの処理を行うということです。

これに、disallowedConcatというコールバック関数に当てます。この関数には、第一位置引数が note で、これは CallExpression のノードのインスタンスですね。

callee の判断

CallExpression には主に callee と parameter という二つの部分によってなります。parameter は説明する必要はないと思いますが、callee というのはそれ以外の部分です。つまり、呼び出された関数およびその前の情報です。

if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
	return;
}

const callee = node.callee as TSESTree.MemberExpression

MemberExpression というのは、つまりオブジェクトのプロパティをアクセスしたりするもので、点や[]によって表される関係。例えば、a.ba[b]などがそれに当たります。

私の目標が、a.concat(b) の形だけなので、CallExpression の callee が MemberExpression の a.concat という形でなければなりません。そうでない場合は、エラーを出す必要がないのでリターンします。

calle.property の判断

MemberExpression も同様に、a.b のように、object としての a と、property としての b によって構成されます。この二つのパーツについて見ていきましょう。

const propertyName = getPropertyName(callee)
if (!propertyName || propertyName !== 'concat') {
    return;
}

property の名前が "concat" である必要があるので、eslint-ast-util にある便利な関数を使って判断します。

calle.object の判断

const tsNodeMap = parserServices.esTreeNodeToTSNodeMap.get(node.callee.object)
const type = checker!.getTypeAtLocation(tsNodeMap)
if (!checker.isArrayType(type)) {
    return;
}

同様に、objectArray である必要があります。幸いなことに、Typescript のコンパイラーのタイプチェッカーには、isArrayType という便利なメソッドがあります。しかし、残念ながらそれは export されていないので、直接使うことができません。どうすればいいのかというと、この前作った global.d.ts で、隠された isArrayType を取り戻します。

次いでに eslint-ast-utils にも型が定義されていないので、序でに付けます。

global.d.ts
declare module 'eslint-ast-utils' {
  export function getPropertyName(node: TSESTree.MemberExpression): string | undefined;
}

declare module 'typescript' {
  interface TypeChecker {
    // internal TS APIs

    /**
     * @returns `true` if the given type is an array type:
     * - `Array<foo>`
     * - `ReadonlyArray<foo>`
     * - `foo[]`
     * - `readonly foo[]`
     */
    isArrayType(type: Type): type is TypeReference;
    /**
     * @returns `true` if the given type is a tuple type:
     * - `[foo]`
     * - `readonly [foo]`
     */
    isTupleType(type: Type): type is TupleTypeReference;
  }
}

すると、エラーが出ずに使えるようになりました。

ESLint に報告する

このように、すべての例外状況を一つずつ潰していきましたので、残りは本当に Array.prototype.concat が使われる状況しかありません(例えそれが書き換えられても、その事実は変わらない)。

context.report({
	messageId: "noArrayConcat",
	loc: callee.property.loc,
	node
})

これで、context.report で報告すると、正常にエラーが出るはずです。

テストする

正常に作動するかどうか、以下のコマンドで確認します。

pnpm run test

ビルドして完成

pnpm run build

これで完成です!

npm に上げる

pnpm run release

以上

結論

これで丁度十時間が経ちました。また色々拙くて、調べまくって試しまくってやっとできましたね!やったー!勝利!

これで快適な lint 生活が始められそうです!皆さんも、何か間違いやすいところがあれば、ぜひ自分が気持ちいいように、lint を作ってみてはいかかでしょうか~

Discussion