🐁

新卒2年目でもESLintで簡単にコーディングルールを追加できた!

2023/11/08に公開

はじめに

これまではUIデザインレビューを目視で行ってきたものの、チェックの負荷が非常に高いという課題がありました。
そこでESLintを使いデザインの規則をコーディングルールとして追加し負荷を軽減しようという試みがあり、新卒2年目の私が担当になったのでやり方を記事にしてみました。

上記を踏まえ、ESLintのカスタムルールの書き方をまとめてみました。

作りたいもの

以下のようなレイアウト実装の観点をエディタ上で確認できるようにしたい

  • color、fontSize を直接指定していないか
  • position: absolute を利用していないか
  • ネガティブマージンを利用していないか

今回は「margin」などのプロパティにネガティブな値を指定した際に警告を表示するルールを作ります。

実際に作ってみる

大まかな流れ

  • 作る必要のあるパターンを洗い出す
    • プロパティの指定のされ方
    • stringなのかnumberなのか、など
  • 洗い出したパターンをテストケースに起こす
    • 成功するケース
    • 失敗するケース
  • ルールを作成する
    • ※作成したルールファイルは、プロジェクトルートのESLint用フォルダのindexから呼び出す必要がある
  • テストを実行する

テストファイルの作成

作る必要のあるパターン

  • マイナス演算子 + numberで指定しているもの
  • stringで指定しているもの
// マイナス演算子 + numberで指定
const style1 = {
    margin: -1
}

// stringで指定
const style2 = {
    margin: "-1px"
}

これらをカスタムルールで検知するので、テストとしては以下のようになりました。

const rule = require("../rules/no-negative-margin");
const RuleTester = require("eslint").RuleTester;

const ruleTester = new RuleTester({
    parserOptions: {
        ecmaVersion: 2018,
    },
});

const errors = [{ messageId: "isNegativeMargin" }];

ruleTester.run("no-negative-margin", rule, {
    valid: [
        {
            code: `const style = {margin: 1}`,
        },
        {
            code: `const style = {marginTop: 1}`,
        },
        {
            code: `const style = {marginRight: 1}`,
        },
        {
            code: `const style = {marginBottom: 1}`,
        },
        {
            code: `const style = {marginLeft: 1}`,
        },
        {
            code: `const style = {top: 1}`,
        },
        {
            code: `const style = {right: 1}`,
        },
        {
            code: `const style = {bottom: 1}`,
        },
        {
            code: `const style = {left: 1}`,
        },
        {
            code: `const style = {margin: "1px"}`,
        },
        {
            code: `const style = {margin: "1px 1px 1px 1px"}`,
        },
        {
            code: `const style = {marginTop: "1px"}`,
        },
        {
            code: `const style = {marginRight: "1px"}`,
        },
        {
            code: `const style = {marginBottom: "1px"}`,
        },
        {
            code: `const style = {marginLeft: "1px"}`,
        },
        {
            code: `const style = {top: "1px"}`,
        },
        {
            code: `const style = {right: "1px"}`,
        },
        {
            code: `const style = {bottom: "1px"}`,
        },
        {
            code: `const style = {left: "1px"}`,
        },
    ],
    invalid: [
        {
            code: `const style = {top: -1}`,
            errors: errors,
        },
        {
            code: `const style = {right: -1}`,
            errors: errors,
        },
        {
            code: `const style = {bottom: -1}`,
            errors: errors,
        },
        {
            code: `const style = {left: -1}`,
            errors: errors,
        },
        {
            code: `const style = {margin: -1}`,
            errors: errors,
        },
        {
            code: `const style = {marginTop: -1}`,
            errors: errors,
        },
        {
            code: `const style = {marginRight: -1}`,
            errors: errors,
        },
        {
            code: `const style = {marginBottom: -1}`,
            errors: errors,
        },
        {
            code: `const style = {marginLeft: -1}`,
            errors: errors,
        },

        {
            code: `const style = {top: "-1px"}`,
            errors: errors,
        },
        {
            code: `const style = {right: "-1px"}`,
            errors: errors,
        },
        {
            code: `const style = {bottom: "-1px"}`,
            errors: errors,
        },
        {
            code: `const style = {left: "-1px"}`,
            errors: errors,
        },
        {
            code: `const style = {margin: "-1px"}`,
            errors: errors,
        },
        {
            code: `const style = {margin: "-1px 1px 1px"}`,
            errors: errors,
        },
        {
            code: `const style = {margin: "1px -1px 1px"}`,
            errors: errors,
        },
        {
            code: `const style = {margin: "1px 1px -1px"}`,
            errors: errors,
        },
        {
            code: `const style = {marginTop: "-1px"}`,
            errors: errors,
        },
        {
            code: `const style = {marginRight: "-1px"}`,
            errors: errors,
        },
        {
            code: `const style = {marginBottom: "-1px"}`,
            errors: errors,
        },
        {
            code: `const style = {marginLeft: "-1px"}`,
            errors: errors,
        },
    ],
});

ルールファイルの作成

"use strict";

// 許可されるプロパティ名の正規表現
const ALLOWED_PROPERTY_REGEX = /^(top|right|bottom|left|margin|marginTop|marginRight|marginBottom|marginLeft)$/;

// マイナス値を含む文字列の正規表現
const NEGATIVE_VALUE_REGEX = /^.*-\d+px.*$/;

module.exports = {
    meta: {
        type: "problem",
        schema: [],
        docs: {
            recommended: true,
        },
        messages: {
            isNegativeMargin: "ネガティブマージンは非推奨です",
        },
    },
    create: context => {
        return {
            // Propertyノードに対する処理
            Property: node => {
                const propertyName = node.key.name;

                // 対象のプロパティ名でない場合は無視
                if (
                    propertyName == null ||
                    typeof propertyName !== "string" ||
                    !propertyName.match(ALLOWED_PROPERTY_REGEX)
                ) {
                    return;
                }

                const propertyValue = node.value;

                // 存在確認
                if (propertyValue == null || !("type" in propertyValue)) {
                    return;
                }

                const valueType = propertyValue.type;

                // プロパティ値がUnaryExpression型でプロパティにoperatorが存在するとき
                if (
                    valueType === "UnaryExpression" &&
                    "operator" in propertyValue
                ) {
                    const operator = propertyValue.operator;

                    // 存在確認
                    if (operator == null) {
                        return;
                    }

                    // マイナス値を指定していない場合は無視
                    if (operator !== "-") {
                        return;
                    }

                    // 報告
                    context.report({
                        node: node,
                        messageId: "isNegativeMargin",
                    });

                    return;
                }

                // プロパティ値がLiteral型でプロパティにvalueが存在するとき
                if (valueType === "Literal" && "value" in propertyValue) {
                    const literalValue = propertyValue.value;

                    // 存在確認
                    if (
                        literalValue == null ||
                        typeof literalValue !== "string"
                    ) {
                        return;
                    }

                    // マイナス値を指定していない場合は無視
                    if (!literalValue.match(NEGATIVE_VALUE_REGEX)) {
                        return;
                    }

                    // 報告
                    context.report({
                        node: node,
                        messageId: "isNegativeMargin",
                    });

                    return;
                }
            },
        };
    },
};

ロジック

create メソッド内にルールのロジックを定義します。
受け取ったAST(抽象構文木)内のノードを検査することで警告を出力したい箇所の判定を行います。

module.exports = {
    meta: {
        type: "problem",
        schema: [],
        docs: {
            recommended: true,
        },
        messages: {
            message: "",
        },
    },
    create: context => {
        return {
            // ここにロジックを書く
        };
    },
};

共通処理

return内でnodeのtypeを指定し、nodeを受け取ります。
今回はプロパティに対する検査を行うので、node上のPropertyを指定し、Propertyノードを扱えるようにします。

Propertyノードkeyを持っており、その階下にname(プロパティ名)があります。
nameを判定し、早期リターンを行っています。

Propertyノードvalueの型は、プロパティの指定の仕方で変わるので型を取り出します。

return {
    // Propertyノードに対する処理
    Property: node => {
        const propertyName = node.key.name; // プロパティ名

        // 対象のプロパティ名でない場合は無視
        if (
            propertyName == null ||
            typeof propertyName !== "string" ||
            !propertyName.match(ALLOWED_PROPERTY_REGEX)
        ) {
            return;
        }

        const propertyValue = node.value;

        // 存在確認
        if (propertyValue == null || !("type" in propertyValue)) {
            return;
        }

        const valueType = propertyValue.type; // プロパティの型
    },
};

対象となったnodeとmessagesに紐づくmessageIdを渡すことで警告を出力します。

// 報告
context.report({
    node: node,
    messageId: "isNegativeMargin",
});

演算子 + numberで値を指定したときのノード

const style1 = {
    margin: -1 
}
{
  "type": "Property",
  "key": {
    "type": "Identifier",
    "name": "margin"
  },
  "value": {
    "type": "UnaryExpression",
    "operator": "-",
    "argument": {
      "type": "Literal",
      "value": 1,
    }
  },
}

演算子 + numberで値を指定したとき、Propertyノードvalueの型UnaryExpressionとなるので、UnaryExpressionに絞り込んでいます。

// プロパティ値がUnaryExpression型でプロパティにoperatorが存在するとき
if (valueType === "UnaryExpression" && "operator" in propertyValue) {
    const operator = propertyValue.operator;

    // 存在確認
    if (operator == null) {
        return;
    }

    // マイナス値を指定していない場合は無視
    if (operator !== "-") {
        return;
    }

    // 報告
    context.report({
        node: node,
        messageId: "isNegativeMargin",
    });

    return
}

stringで値を指定したときのノード

const style2 = {
    margin: "-1px" 
}
{
  "type": "Property",
  "key": {
    "type": "Identifier",
    "name": "margin"
  },
  "value": {
    "type": "Literal",
    "value": "-1px"
  }
}

stringで値を指定したとき、Propertyノードvalueの型Literalとなるので、Literalに絞り込んでいます。

// プロパティ値がLiteral型でプロパティにvalueが存在するとき
if (valueType === "Literal" && "value" in propertyValue) {
    const literalValue = propertyValue.value;

    // 存在確認
    if (
        literalValue == null ||
        typeof literalValue !== "string"
    ) {
        return;
    }

    // マイナス値を指定していない場合は無視
    if (!literalValue.match(NEGATIVE_VALUE_REGEX)) {
        return;
    }

    // 報告
    context.report({
        node: node,
        messageId: "isNegativeMargin",
    });

    return;
}

テスト実行

全て問題なし😎

エディタ上

こんな感じで表示してくれます!😎

まとめ

ところどころ気になる点があるかもしれませんが、カスタムルールは動きました!

コーディングに規則性を持たせることで、品質や保守性の担保に繋がると思うので、
コードレビューの観点などをカスタムルールに起こしてみるのもいかがでしょうか😳

Thinkingsテックブログ

Discussion