自分で ESLint のルールを作ってみた件
PaletteWorks Editor の開発中、あるバグを修正しようとして、いろいろコード読むわけじゃないですか……それで五回くらい読んでも見逃しちゃったんですよね……
concat()
という邪悪なものを
意識してみるとそうでもないけど、意識せずに見ると全然見逃しちゃうんですよね……なんか、filter
とかmap
とかforEach
の類に脳が分類しちゃって、気づけなくなってる
だからどうせ[...a, ...b]
という文法があるし、そっち使ったら全然わかりやすいし、実際開発途中で段々意識せずにそれにリファクターした部分もあります。
リファクターしたものがこうなります。まぁ大部分かりやすくなったのではないでしょうか。
目標
ということで、実際に ESlint のルールを作って、concat
を消滅させよう!
TL;DR
レポです。(Initial Commit に変更が集中したのは、いろいろ弄りまくっても上手く行かず、やっと成功した時に遂に git したというのが理由です)
NPM です
前提知識
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 を他のものを変えるだけなので、この例ではそのままにする)
{
"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"
},
}
{
"compilerOptions": {
"target": "ES2015",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"typeRoots": [
"./"
]
},
"include": [
"src",
"global.d.ts",
],
"exclude": ["node_modules", "tests"]
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
},
"files": [
"estree.ts"
],
"include": [
"tests/**/*.ts",
"global.d.ts"
]
}
module.exports = {
preset: 'ts-jest',
testMatch: ['**/tests/**/*.ts'],
globals: {
'ts-jest': {
tsconfig: './tsconfig.test.json'
},
},
}
node_modules/
dist/
フォルダ構成が以下になります。
ドキュメントを書く
まず軽くドキュメントを書いて整理しましょう。docs/rules/no-array-concat.md
簡単に契機・目的、通るものと通らないもの、使わない時は?、リソース・リンクなど、まぁ公式のドキュメント構成を参照すればいいと思います。
これでわかったのは、やるべきことが、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
というルールでも、単に名前から map
、filter
、some
を判定していて、Array ではないものまで影響がでてしまいます。これもこのルールが recommend になっていない理由かもしれません。
どうしよう……
解決へ
Javascript と型と言えば、何が脳裏に浮かぶでしょうか。そう、Typescript です!Typescript には型の情報が含まれています。それを利用すれば、以上のことが解決できるのではないでしょうか。
ということで、Typescript 関連のものはどうやって lint されていたのでしょう。実は、Typescript ESLint という大変ありがたいものがあります。実際、Typescript のコードもこれに掛かれば、簡単に検証できます。
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
準備する
以上のフォルダ構成になるようにファイルを作り、ヘルパーモジュールを書きます。
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`
})
module.exports = {
rules: {
'no-array-concat': require('./rules/no-array-concat'),
}
}
テストケースを書く
型検査機能が使われる場合、テストを行うには実際に .tsconfig.json
が必要で、そのために RuleTester で設定しなければならない。内容は特になんでもいいですが、files
に estree.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" }],
}
],
});
ルールを書く
ということで、実際にルールを書いていきましょう。
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.b
やa[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;
}
同様に、object
が Array
である必要があります。幸いなことに、Typescript のコンパイラーのタイプチェッカーには、isArrayType
という便利なメソッドがあります。しかし、残念ながらそれは export されていないので、直接使うことができません。どうすればいいのかというと、この前作った global.d.ts
で、隠された isArrayType
を取り戻します。
次いでに eslint-ast-utils
にも型が定義されていないので、序でに付けます。
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