淺談 AST 及 ESlint Rule:撰寫屬於自己的 ESLint Rule(下)

前言

這篇延續了上一篇對於 AST 的介紹,基於上篇對 AST 的理解,這篇文會開始實際使用 AST Explorer 以及 ESLint 提供的 API,實際的撰寫屬於自己專案的 ESLint Rule。

ESLint Rule 的基礎設置、介紹

ESLint Rule 執行流程

ESLint 的 rule 在執行上的流程經過簡化後,大致如下:

  1. ESLint parser 將 code 解析成 AST
  2. 透過 ESLint 的 selector 選擇 node,設置 rule 的 listener
  3. ESLint traverse AST 時,觸發 listener 的 callback

⚠️ 上面的流程不完全是 ESLint 運作的方式,只是我個人經過閱讀文章後簡化的理解,ESLint Rule 背後執行還牽涉到 Visitor Pattern,如果對背後實際運行方式有興趣,也有許多 ESLint source code 解讀的文章。

ESLint 的規則寫起來其實蠻像一般在寫 JavaScript 設置 eventLisnter 的感覺,透過選擇器(selector) 來選擇到想要的 node,接著 rule 的 listener 就會被放到 node 上,等到 ESLint traverse 到那個 node 的時候,就會觸發 rule 的檢查。

Selector 是什麼?

可以去 ESLint - Selectors 看到以下的說明:

A selector is a string that can be used to match nodes in an Abstract Syntax Tree (AST). This is useful for describing a particular syntax pattern in your code.

The syntax for AST selectors is similar to the syntax for CSS selectors. If you’ve used CSS selectors before, the syntax for AST selectors should be easy to understand.

簡單來說它就是類似於 CSS selectors 的東東,讓你可以很輕鬆的從選擇到想要的 AST node。

舉例來說,假設我們要選擇 FunctionDeclaration 底下的 Identifier,那就可以寫成這樣子:

module.exports = {
  create(context) {
    // ...
    return {
      // 透過這裡來選擇到想要的 node
      "FunctionDeclaration > Identifier": function(node) {
        // listener 的 callback 邏輯寫在這裡
      },
    };
  }
};

透過很像 CSS Selectors 的運作模式,就可以選擇到自己想要的節點了。

ESLint Rule 的基本架構

下面的程式碼是 ESLint 規則的基本長相

/**
 * @fileoverview Rule to disallow unnecessary semicolons
 * @author Nicholas C. Zakas
 */

"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "disallow unnecessary semicolons",
      category: "Possible Errors",
      recommended: true,
      url: "https://eslint.org/docs/rules/no-extra-semi"
    },
    fixable: "code",
    schema: [] // no options
  },
  create: function(context) {
    return {
      // callback functions
    };
  }
};

主要分為 metacreate 兩個部分,meta 負責的是一些 ESLint Rule 的說明、設定等等,比如 docs 就是關於文件相關的說明,fixable 則是要不要啟用 quick fix 功能等等的;create 就是我們 callback 內容,ESLint Rule 實際的運作邏輯就會寫在這裡。

ESLint Rule 的資料夾結構

ESLint 官方文件在 Working with Rules 有說到 ESLint Rule 的資料夾結構該長怎樣:

  • in the lib/rules directory: ESLint Rule 的實際邏輯會放在這,檔名就會是這個規則的名稱 (for example, no-extra-semi.js)
  • in the tests/lib/rules directory: ESLint Rule 測試的檔案,檔名也是規則名稱 (for example, no-extra-semi.js)
  • in the docs/rules directory: 關於規則說明的 markdown 放在這 (for example, no-extra-semi.md)

快速產出 ESLint Plugin、Rule 的模板

ESLint Plugin 的模板要自己慢慢新增也可以,不過目前已經有非常方便的 CLI 可以使用,只要咻咻打幾個字就可以產出 ESLint Plugin 的各種模版,非常滴爽。

ESLint Plugin

首先要安裝 yo 以及 generator-eslint

npm install -g yo generator-eslint

接著建立 ESLint Plugin 資料夾的名稱

mkdir yang-eslint-plugin

接著輸入 CLI,建立 ESLint Plugin 的模板

yo eslint:plugin

它會問幾個設定上的問題

? What is your name? ChihYang
? What is the plugin ID? yanglint
? Type a short description of this plugin: yang 專屬的 ESLint Plugin 
? Does this plugin contain custom ESLint rules? Yes 
? Does this plugin contain one or more processors? No

接著就會生成基本的 Plugin 模板了,但這只是 Plugin

ESLint Rule

接著就要創建 Rule 的模板啦,首先輸入

yo eslint:rule

接著一樣是會問一些簡單的設定問題

? What is your name? ChihYang
? Where will this rule be published? (Use arrow keys) 
  ❯ ESLint Core  
    ESLint Plugin 
? What is the rule ID? async-function-name  
? Type a short description of this rule: blablabla

這樣就會產出需要的 ESLint Rule 檔案了!

ESLint Rule 實戰一:抓出不合規範的命名

前面囉哩八嗦講一堆設定的介紹,接下來就真的要到實戰的環節了,首先來到我們第一個 ESLint Rule 的範例,「Async Function Name」,相信在一般團隊開發中都會有部分的命名是希望統一的,比如發 GET Method 的 request 會用 getXXX 開頭之類的。這個規則也是如此,當我們使用 async function 的時候,會希望 function 的名稱是 xxxxAsync 結尾,屬於一個團隊上的命名規範。

所以需求如下:

實作一個可以檢查 async function 命名的 rule,funcion name 結尾需要有 「Async」

所以實際上的範例如下,invalid 範例會無法通過 ESLint Rule 檢查:

// invalid,沒有 Async 結尾
async function foo() {
	/* .... */
}

// valid,有 Async 結尾
async function getListAsync() {
	/* .... */
}

了解需求以後,接著就來實戰吧!

第一步:實作之前,先寫測試

接下來想走一個 TDD 的方式,先寫下測試以後,再開始實作 ESLint Rule 的邏輯,雖然我是沒試過 TDD 的開發方式啦,但感覺很好玩,所以就這樣做吧!

如何做 ESLint Rule 的測試呢?ESLint 有提供一個 RuleTester 的東東:

var rule = require('../../../lib/rules/async-function-name')
var RuleTester = require('eslint').RuleTester
var ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2018 } })

ruleTester.run('async-function-name', rule, {
  valid: [],
  invalid: [],
})

這邊做的事情就是引入 rule 的 module、從 ESLint 引入 RuleTester,以及使用 RuleTester new 出一個 instance,instance 的 constructor 可以傳入一些 option 的設定,因為 async/await 是 ES7 的語法,所以在 parser 的 version 我就設定為 2018,不然 RuleTester 是沒有辦法測試的。

接著執行 ruleTester.run,第一個參數是 rule 名稱;第二個參數是 rule module;第三個則是實際的測試案例,valid 如字面意思就是合法的程式碼 test case,invalid 則反之。

了解到這些以後,我們就可以寫下 test cases 了:

var rule = require('../../../lib/rules/async-function-name')
var RuleTester = require('eslint').RuleTester
var ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2018 } })

ruleTester.run('async-function-name', rule, {
  valid: [
    'function foo() { console.log() }',
    {
      code: 'async function fooAsync() { return "" }',
    },
  ],

  invalid: [
    {
      code: 'async function myFunction() { return "";}',
      errors: [
        {
          message: 'async function name should have Async words',
        },
      ],
    },
    {
      code: 'async function myFunctionasync() { return "";}',
      errors: [
        {
          message: 'async function name should have Async words',
        },
      ],
    },
    {
      code: 'async function myAsyncFunction() { return "";}',
      errors: [
        {
          message: 'async function name should have Async words',
        },
      ],
    },
  ],
})

不合法 test case 的包括了「async function 但沒有 Async 結尾」、「async function 但結尾不是 camel case」、「async function 但 Async 不是放在結尾」

接著我們就可以把這些測試案例貼在 AST Explorer:

AST Explorer 連結

截圖 2021-08-10 上午12.06.49

第二步:觀察 AST,找出想要操作的 node

我們需要的資訊有兩個:

  1. 檢查 function name 結尾有沒有 Async
  2. 檢查 function 是不是 async function

知道需要哪些資訊以後,接著就來觀察 AST 裡面哪些地方放有這些資訊:

截圖 2021-08-10 上午12.09.45

從上圖可以發現到一個 function 的宣告就會是一個 FunctionDeclaration 的 node,接著仔細看 FunctionDeclaration 的內部,會看到:

截圖 2021-08-10 上午12.10.51

FunctionDeclaration 中還有 id 裡的 IdentifierIdentifier 的 name 就會是 function 的名稱,如果我們要檢查 function name 是否有「Async」的話,Identifier 的 name 就會是我需要的資訊;另外,FunctionDeclaration 當中會有一個叫 async 的 boolean,這就是 function 是否為 async function 的資訊,這就是我們所需的兩個資訊!

接著,以我們的需求來看,我們需要選擇到 FunctionDeclaration 來得到 function 的資訊並做名稱相關的檢查,不寫成 "FunctionDeclaration > Identifier" 的原因是除了 function name 檢查以外,我們也要確定這個 function 是 async,而這個資訊只會在 FunctionDeclarationasync 才有。

了解該做什麼後,現在就移動到 AST Explore 並寫下 code:

module.exports = {
  meta: {},

  create: function (context) {
    return {
      FunctionDeclaration: function (node) {
        if (node.async && !/Async$/.test(node.id.name)) {
          context.report({
            node,
            message: 'async function name should have Async words',
          })
        }
      },
    }
  },
}

node 會是我們選擇到的 FunctionDeclaration 這幾個 node,得到 FunctionDeclaration 的資訊後,我們就可以透過 node.async 檢查是否為 async function,以及用 regex 檢查 function name 結尾是否符合規範。

當符合條件後,代表這就是我們想要揪出的錯誤命名 function,透過 contex.report 可以回報找出的錯誤,傳入 node 可以讓 ESLnt 幫你指出錯誤的地方在哪,message 則是想要跳出的錯誤提示文字。

所以在 AST Exploer 可以在右下角看到這樣的 output:
截圖 2021-08-10 上午12.20.00

ESLint 成功幫我們找出錯誤了!

ESLint Rule 實戰二:don’t call this function please🥺

完成第一個實戰範例了,接下來再看看第二個範例吧,這個範例源自於工作時專案上看到的狀況,不禁就想試看看能怎麼用 ESLint 自動化的處理。

前情提要是我在專案上看到這個 function:

// 此 function 用於測試,不要在測試以外的檔案使用,避免造成非預期的結果!
function DANGEROUS_reset() {
  /* ... */
}

一般來說沒有 eslint 輔助的話,就只能在 comments 上面記得這件事情,但如果意外的使用了這個 function,也沒辦法得知,就會產生非預期的 bug,因此我們可以試試看自己寫個 ESLint Rule 來警告自己不要在測試外的地方使用這個 function!

需求如下:

  • 檢查在 test 以外的檔案有沒有非預期的 function call
  • 可以在 array 列舉出一連串禁用的 fucntion
  • 如果能自動修正錯誤就更棒了

出現 warning 的範例:

// App.js
import { DANGEROUS_reset } from './utils'  

DANGEROUS_reset() // 欸,call 屁喔!

第一步:實作之前,一樣先寫測試

如同前一個範例,我們還是先寫測試

var rule = require('../../../lib/rules/do-not-call-this-function')
var RuleTester = require('eslint').RuleTester
var ruleTester = new RuleTester()

ruleTester.run('do-not-call-this-function', rule, {
  valid: [
    'foo()',
    'console.log()',
    'getData()',
  ],

  invalid: [
    {
      code: 'resetAll()',
      errors: [
        {
          messageId: 'dontCallMsg',
        },
      ],
    },
    {
      code: 'destroyApp()',
      errors: [
        {
          messageId: 'dontCallMsg',
        },
      ],
    },
    {
      code: 'printError()',
      errors: [
        {
          messageId: 'dontCallMsg',
        },
      ],
    },
  ],
})

在預期中,resetAlldestroyAppprintError 這些是不希望在測試檔案以外地方呼叫的 function,所以列為不合法的 test cases。

另外會發現到這邊出現 error message 的方式改成寫 messageId: 'dontCallMsg' 了,這也是 ESLint Rule 提供的功能,叫做 messageId,屬於一個更簡單去寫錯誤提示的方式,有興趣可以看 ESLint - messageId 了解如何使用。

接下來一樣是把 test cases 貼到 AST Explorer 上面:

AST Explorer 連結

截圖 2021-08-10 上午12.29.29

第二步:觀察 AST,selector 對準獵物

因為我們要檢查的是「function call 名稱是否在黑名單裡面」,所以需要的是 function call 的 name,那這個資訊會在哪裡出現呢?觀察了 AST 以後可以看到:

截圖 2021-08-10 上午12.32.48

CallExpression 當中的 Identifiername 就是 function call 的名稱,因此這就是我們要的資訊!

知道要選取誰以後就很簡單了,用 selector 選擇到想要的 node:

AST Explorer 連結

module.exports = {
  meta: {
    docs: {
      description: 'dont call this function out of test file',
      category: 'Fill me in',
      recommended: false,
    },
    fixable: 'code', // or "code" or "whitespace"
    schema: [
    ],
    messages: {
      dontCallMsg: 'dont call this function out of test file',
    }
  },

  create: function (context) {
    return {
      'CallExpression > Identifier': function (node) {
      
      },
    }
  },
}

第三步:撰寫 Rule 邏輯

接著就是實際撰寫 Rule 的檢查邏輯,我預計最基礎的作法就是可以用一個 array 存放黑名單的 function name,如果檢查到 node 的名稱在黑名單裡面的話,就需要揪出來,所以實作邏輯如下:

AST Explorer - disallowedMethods

const disallowedMethods = ['resetAll', 'destroyApp', 'printError']

module.exports = {
  meta: {
    docs: {
      description: 'dont call this function out of test file',
      category: 'Fill me in',
      recommended: false,
    },
    fixable: 'code', // or "code" or "whitespace"
    schema: [
    ],
    messages: {
      dontCallMsg: 'dont call this function out of test file',
    }
  },

  create: function (context) {
    return {
      'CallExpression > Identifier': function (node) {
      	const isDisallowed = disallowedMethods.includes(node.name)

        if (isDisallowed) {
          context.report({
            node,
            messageId: 'dontCallMsg',
          })
        }
      },
    }
  },
}

單純就只是用 includes 這個內建函式去檢查 function call 的名稱。

另外,需求上還有說要檢查的是「測試檔案」以外的地方,關於這個需求可以到 eslintrc.js 裡面的 overrides 做設定:

rules 就是一般設定想要開啟的規則,但如果有些檔案想要部分套用規則而已,overrides 可以設定哪些檔案想開啟、關閉哪些規則,做出更客製化的 ESLint 設定套用。

中場休息:盤點 TODO

  1. 檢查 test 以外的檔案有沒有非預期的 function call
  2. 可以在 array 列舉出一連串禁用的 fucntion
  3. 如果能自動修正錯誤就更棒了

第一個功能確定完成了,但第二個功能其實不太彈性,只完成了基本的需求,但仍然有美中不足的地方,不太理想的地方在哪呢?就是我們想要禁用的 function 名稱被寫死在 ESLint Rule 裡面了,比如今天臨時又想要新增幾個黑名單的 function name,以目前的方式來說就只能直接去修改 ESLint Rule 內部的邏輯,其實不是很方便,那有沒有更彈性的方式來實作呢?目前有想到一個,就是透過 ESLint Rule 裡面 options,我希望可以做到的事情會像是這樣:

module.exports = {
  rules: {
    'yanglint/async-function-name': ['error'],
    'yanglint/do-not-call-this-function': ['warn', { disallowedMethods: ['resetAll', 'printError'] }],
  },
};

透過 options 的設定,我們就可以在設定檔傳入想要加入黑名單的 function,之後我們可以在 create 裡面透過 context.options 接收到被傳進的設定:

module.exports = {
  meta: {
    docs: {
      description: 'dont call this function out of test file',
    },
    fixable: null, // or "code" or "whitespace"
    schema: [
      // fill in your schema
    ],
  },

  create: function (context) {
    console.log(context.options) // 收到剛剛傳進來的 disallowedMethods: ['resetAll', 'printError']
  },
}

也就是說我們能夠透過 eslintrc.js 的 options 設置來決定要禁用哪些 function,如此一來就不用改動 ESLint Rule 內部的邏輯了,聽起來讚讚,開始實作吧!

ESLint Rule Option

要做到這點會用到 ESLint Rule 裡面的 metaschema,還記得 meta 這東西嗎?忘記的話再來看看:

module.exports = {
  meta: {
    docs: {
      description: 'dont call this function out of test file',
    },
    fixable: null, // or "code" or "whitespace"
    schema: [
      // fill in your schema
    ],
  },

  create: function (context) {
    return {
      /* ... */
    }
  },
}

schema 就是用來規定這個 ESLint Rule 的 options 預計接收的資料格式,那如何去規定資料格式要長怎樣呢?當中會使用一個叫 JSON Schema 的東東來制定資料格式,JSON Schema 怎麼使用呢?

舉例來說,假設我們接收的 JSON 資料長這樣:

{
  "name": "ChihYang",
  "age": 18,
  "gender": "male"
}

這段 JSON Data 的 JSON Schema 就會長這樣:

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "minLength": 4
    },
    "age": {
      "type": "integer",
      "minimum": 0,
      "maximum": 130
    },
    "gender": {
      "type": "string",
      "enum": [
        "male",
        "female"
      ]
    }
  }
}

簡單來說就是用 JSON 來定義 JSON data 的細節,比如 type、enum 內容、最小值最大值等等的。

知道如何使用以後,回到 ESLint Rule metaschema 就知道該怎麼定義了:

module.exports = {
  meta: {
    docs: {
      description: 'dont call this function out of test file',
    },
    fixable: null, // or "code" or "whitespace"
    schema: [ /* {disallowedMethods: ['resetAll', 'printError']} */
      {
        type: 'object',
        properties: {
          disallowedMethods: {
            type: 'array',
            items: {
              type: 'string',
            },
            minItems: 1,
            uniqueItems: true,
          },
        },
      },
    ],
  },

  create: function (context) {
    return {
      /* ... 省略 ... */
    }
  },
}

接著,我們的測試也需要調整,RuleTester 很讚的地方是每個 test case 也可以決定要傳什麼樣的 option,讓我們可以測試傳入不同 option 的 case:

ruleTester.run('do-not-call-this-function', rule, {
  valid: [
    'foo()',
    'console.log()',
    'getData()',
    {
      code: 'resetAll()',
      options: [{ disallowedMethods: ['destroyApp', 'printError'] }],
    },
  ],

  invalid: [
    {
      code: 'resetAll()',
      options: [{ disallowedMethods: ['resetAll', 'printError'] }],
      errors: [
        {
          messageId: 'dontCallMsg',
        },
      ],
    },
    {
      code: 'destroyApp()',
      options: [{ disallowedMethods: ['destroyApp', 'resetAll'] }],
      errors: [
        {
          messageId: 'dontCallMsg',
        },
      ],
    },
    {
      code: 'printError()',
      options: [{ disallowedMethods: ['printError'] }],
      errors: [
        {
          messageId: 'dontCallMsg',
        },
      ],
    },
  ],
})

我們測試涵蓋了幾種情況:

  1. 沒有傳入任何 options 的時候,因為這時候沒有禁用的 function,所以會通過
  2. 有在 options 傳入 disallowedMethods,但 call 的 function 和 disallowedMethods 不同,所以會通過
  3. call 了 options 規定的 disallowedMethods,這種情況不通過

接著就是實際調整 ESLint Rule 的邏輯了,改的地方也不多,只是把原本宣告的 disallowedMethods 改成從 context.options 取得:

module.exports = {
  meta: {
    docs: {
      description: 'dont call this function out of test file',
      category: 'Fill me in',
      recommended: false,
    },
    fixable: null, // or "code" or "whitespace"
    schema: [
      {
        type: 'object',
        properties: {
          disallowedMethods: {
            type: 'array',
            items: {
              type: 'string',
            },
            minItems: 1,
            uniqueItems: true,
          },
        },
      },
    ],
  },

  create: function (context) {
    return {
      'CallExpression > Identifier': function (node) {
        const disallowedMethods = context.options.length
          ? context.options[0].disallowedMethods
          : []

        const isDisallowed = disallowedMethods.includes(node.name)

        if (isDisallowed) {
          context.report({
            node,
            message: 'dont call this function',
          })
        }
      },
    }
  },
}

中場休息:再次盤點 TODO

  1. 檢查 test 以外的檔案有沒有非預期的 function call
  2. 可以在 array 列舉出一連串禁用的 fucntion
  3. 如果能自動修正錯誤就更棒了

這次我們終於完成更彈性的寫法了,剩下最後一個「自動修正錯誤」的功能,相信使用過 ESLint 的人都一定看過一個功能是在我們將滑鼠移到 warning 上得時候,會出現一個叫「quick fix」的功能,點下去之後就會神奇地幫我們修正那個錯誤,如下圖:

這個功能是怎麼做到的呢?這就要講到 ESLint Rule 的 fixer 了。

ESLint Rule:Fixer

回到我們的需求來說:

檢查在 test 以外的檔案是否有執行這個 function,有的話跳出 warning,如果能自動修正掉就更棒了。

這邊我把「自動修正錯誤」定義為「自動幫忙移除掉這個不合法的 function call」,直接移除掉某段程式碼的做法似乎有點極端,但方便起見就先這樣吧。

至於怎麼刪除掉呢?ESLint 有提供一個叫 fixer 的物件。當我們在使用 context.report 的時候,可以傳入 fixfix 會是一個 callback function,它會接收到 fixer 這個 object:

context.report({
  node: node,
  message: "Missing semicolon",
  fix: function(fixer) {
    return fixer.insertTextAfter(node, ";");
  }
});

fixer 當中提供了不少的 method 可以使用:

截圖 2021-08-11 下午9.27.01

以目前的需求為例,我們要做的是「移除掉某段不合法 function call」,所以照理來說會用到的是 remove ,知道可以用什麼 API 以後,就可以繼續往下了。

不過在真正刪除 node 之前,要先要到 ESLint Rule 的 meta 調整 fixable

module.exports = {
  meta: {
    docs: {
      description: 'dont call this function out of test file',
    },
    fixable: 'code', // 把這裡改成 code
    schema: [
      /*... 省略 ... */
    ],
  },

  create: function (context) {
    return {
      /* ... 省略 *.../
    }
  },
}

接著我們還是要調整測試:

ruleTester.run('do-not-call-this-function', rule, {
  valid: [
    'foo()',
    'console.log()',
    'getData()',
    {
      code: 'resetAll()',
      options: [{ disallowedMethods: ['destroyApp', 'printError'] }],
    },
  ],

  invalid: [
    {
      code: 'resetAll()',
      options: [{ disallowedMethods: ['resetAll', 'printError'] }],
      errors: [
        {
          message: 'dont call this function',
          type: 'Identifier',
        },
      ],
      output: '', // 新增了這行
    },
    {
      code: 'destroyApp()',
      options: [{ disallowedMethods: ['destroyApp', 'resetAll'] }],
      errors: [
        {
          message: 'dont call this function',
          type: 'Identifier',
        },
      ],
      output: '', // 新增了這行
    },
    {
      code: 'printError()',
      options: [{ disallowedMethods: ['printError'] }],
      errors: [
        {
          message: 'dont call this function',
          type: 'Identifier',
        },
      ],
      output: '', // 新增了這行
    },
  ],
})

這邊的 output 指的是「經過 quick fix 修正過後,預期的程式碼長怎麼樣」,因為我們會把整段程式碼移除掉的緣故,所以 output 會什麼都沒有。

接著就到了快樂的觀察 AST 時間了,來觀察一下我們想要刪除的是哪個 node?

截圖 2021-08-11 下午9.23.08

以一開始的直覺來說,應該會想刪除掉的是 CallExperssion ,因為是要移除掉 function call,這想法也很合理,不過這邊會發現只要在程式碼中加個分號:

截圖 2021-08-11 下午9.23.23

就發現 CallExperssion 沒有包含那個分號,如果我們使用 fixer.remove 的話,就只會移除掉 function call 而留下分號了,所以這邊預期要移除掉的會是 ExpressionStatement

但要怎麼選擇到 ExpressionStatement?畢竟我們在剛剛使用 selector 選擇 node 的時候是 CallExpression > Identifier,選擇到的 node 都會是最深處的 Identifier,那要如何選擇到更上層的 node 呢?假如我們 console 出目前的 node 的話,可以看到這樣的內容:

截圖 2021-08-11 下午10.04.03

Identifier 這個 node 當中會包含 parent 這個 property,而 parent 就是它上一層的 CallExperssionCallExpression 當然也會有屬於它自己的 parent, CallExpression 的 parent 就會指向 ExpressionStatement,而這就是我們想要刪除的 node。

所以關於 fixer 的程式碼會寫成這樣:

AST Explorer - Fixer

module.exports = {
  meta: {
    docs: {
      description: "don't call this function out of test file",
      category: 'Fill me in',
      recommended: false,
    },
    fixable: 'code'
    schema: [
      {
        type: 'object',
        properties: {
          disallowedMethods: {
            type: 'array',
            items: {
              type: 'string',
            },
            minItems: 1,
            uniqueItems: true,
          },
        },
      },
    ],
  },

  create: function (context) {
    return {
      'CallExpression > Identifier': function (node) {
        const disallowedMethods = context.options.length
          ? context.options[0].disallowedMethods
          : []

        const isDisallowed = disallowedMethods.includes(node.name)

        if (isDisallowed) {
          context.report({
            node,
            message: 'dont call this function',
            fix: function (fixer) {
              return fixer.remove(node.parent.parent)  // 這行!刪除掉 ExpressionStatement
            },
          })
        }
      },
    }
  },
}

這樣就大功告成了!我們自己寫的 ESLint Rule 也具備 quick fix 的功能了。

結尾

有一陣子還會搞混 Prettier 和 ESLint 的功用,畢竟他們在 coding style 的功能上是有部份重疊的,但經過這次研究發現 ESLint 更重要的價值在於提前發現可能的錯誤,它是一個邊寫邊幫你測試程式、檢查錯誤的工具,無法想像如果少了 ESLint 平常的開發會有多不便,專案當中一定也有些邏輯是屬於那個專案獨有的邏輯,這就是個很好去客製 ESLint Rule 時候,減少心智的負擔,交給工具自動去處理。

當初也沒料到自己會想研究這個主題,畢竟抽象語法樹耶聽起來就非常高端(不是在講疫苗),八成是我半輩子也理解不來的東西,但經過簡單的理解以後,雖然對於真正 parser 解析的細節依然不是很清楚,但至少理解了基本運作,AST 也不再恐怖。

似乎至今為止學習一門新知都是這樣,剛開始覺得喔幹我好像是個智障喔,為何什麼都聽不懂??但耐著性子慢慢理解總會越聽越明白,這種從一無所知到漸漸理解,從自我懷疑到越學越有趣,並且實際產出成文章的過程大概就是學習新技術最好玩的地方吧。

可以的話,希望後續還能補個番外篇,講述關於 Visitor Pattern 的概念及實際應用範例。

References