VSCode 語言插件開發入門

when people ask me to recommend a text editor

圖源:Twitter@tpope(https://twitter.com/tpope/status/1172743697315835904)

VSCode 爲何可以支持如此之多的編程語言?如何爲一門新語言編寫語言插件?又有哪些語言特性可以被應用呢?本次分享爲大家介紹了 VSCode 提供的編程語言相關的能力,詳細講解了代碼高亮原理、languages.* API、Language Server Protocol 等內容。

再往下講之前,要先講一下 VSCode 插件入門。有基礎的朋友可以跳過該段往下繼續看。

VSCode 插件極速入門

上圖就是一個插件的全貌,VSCode 插件由兩部分組成:配置聲明和代碼。

我們可以聲明一些配置項,比如代碼入口文件、插件激活的時機、菜單項、快捷鍵等等。

我們的業務邏輯代碼放在代碼入口文件的 activate 函數內,VSCode 在我們聲明的 activationEvents被觸發後,會執行我們的入口函數。

我們在入口函數中通過調用 VSCode 給我們提供的 API(如 vscode.languages.xxx)來做各種功能。

VSCode 提供了非常豐富的 API(數不勝數),比如說用戶可以在編輯器區域、狀態欄等各個地方添加自己的組件;比如說可以操作編輯器、操作文件樹、提示消息等等。同樣的,也有豐富的 API 提供了語言編輯支持,如補全、代碼高亮等等。VSCode 幫我們搭好了一整套架子,我們需要往裏填內容。

什麼是語言插件?

語言插件就是 VSCode 整個插件生態 / 系統中關於 編輯 / 編程語言支持 的那一部分。我們能用 VSCode 編輯各種不同的編程語言,靠的就是這些插件以及背後的開發者。

像我們在 VSCode 中編輯代碼時的語法高亮,自動補全等都是語言插件帶給我們的。VSCode 的本體也是沒有加入各種語言的編輯能力的,它也是靠內置的插件來完成的,如:css-language-features\typescript-language-features 等等。

VSCode 提供了一堆 API (對應不同語言特性的貢獻點)來讓開發者實現各種語言特性。

代碼高亮

自動補全

我們在 VSCode 插件市場上也能看到各種語言插件:

如何實現「語言插件」的這些功能?

VSCode 將提供的語言特性大致分爲了兩種:

  1. 聲明式語言特性

通過編寫配置文件來定義一些特性。

  1. 編程式語言特性

  2. languages.* API

編寫代碼,調用 vscode.languages.* API

  1. Language Server Protocol

編寫遵守 Language Server Protocol 的語言服務器。

聲明式語言特性(Declarative language features)

來個例子:

當我們輸入左符號的時候,會自動補全右符號。

當我們選中一段內容的時候,輸入符號時會自動左右環繞上。

我們可以快速註釋反註釋。

我們通過配置文件來定義一些特性,一些可以做到的特性:

  1. 代碼高亮

  2. Snippet 補全

  3. 括號匹配

  4. 括號自動閉合

  5. 括號 auto surrounding

  6. 註釋 / 反註釋

  7. 縮進

  8. 摺疊

  9. ...

稍微一提:列表中的某幾點,VSCode 也給我們提供了編程配置的方式,用以定義更細緻,精巧的操作,

接下來拿兩點來大概介紹一下:

1、代碼高亮

比如說代碼高亮:

代碼高亮就是在展示代碼的時候將不同的部分用不同的風格和顏色展示,比如註釋和正常代碼的顏色、風格不同,字符串和數字展示的顏色不同。

我們在下文會着重介紹一下代碼高亮。

2、語言配置

比如我們提供一門語言的基礎配置,這個文件控制着基本的編程特性,比如說註釋反 / 註釋,括號匹配 / 補全,摺疊等。

配置也是比較基本的:

  1. 註釋 / 反註釋

可以聲明行級和塊級註釋

  1. 括號定義

可以聲明括號配對,在高亮括號,括號跳轉等地方會用到。

  1. 符號自動關閉 (auto closing)

當我們輸入一個單個字符的時候,比如 ' ,如果文本後面是空格,VSC 可以自動幫我們補全另一個 '。我們可以定義這些字符。

  1. 符號自動環繞 (auto surrounding)

當我們選中一段文本的時候,輸入這個符號,VSC 會幫我們在選中文本前後輸入這一對符號。

  1. 摺疊

VSC 默認支持根據縮進的摺疊,也可以支持定義的摺疊標記。也可以通過 Language Server 返回相應的 textDocument/foldingRange 請求來摺疊。

  1. ...

參考:https://code.visualstudio.com/api/language-extensions/language-configuration-guide

編程式語言特性(Programmatic language features)

還有一些特性是我們需要編寫程序來實現的,比如說自動補全,代碼診斷,定義跳轉;這個程序會分析你的代碼然後給出各個功能的建議。

來個比較稀有的栗子:

比如 selectionRange 這種操作,在 html 中可以先選中屬性,再選中開始標籤,再選中整塊內容。這就需要我們編寫程序解析用戶的代碼,生成可選取的範圍並返回給 VSCode。

比如這就是一個在 HTML 中解析 selectionRange 的代碼:

const doSelectionRanges = (document: TextDocument, positions: Position[]): SelectionRange[] => {
  const getSelectionRange = (position: Position): SelectionRange => {
    // 代碼不重要
  }
  return positions.map(getSelectionRange);
}
vscode.languages.registerSelectionRangeProvider(
  {
    scheme: "file",
    language: "html",
  },
  {
    provideSelectionRanges(document, positions, token) {
      return doSelectionRanges(document, positions);
    },
  }
);

我們通過調用 vscode.languages.registerSelectionRangeProvider 這個 API,傳入條件(第一個參數)和具體執行代碼的回調函數(第二個參數)。當用戶在滿足條件的文件中執行 selectionRange 的操作時,VSCode 會查找到我們傳入的回調並執行,然後根據執行結果做出正確表現。

VSCode 提供了各種各樣的 API,涵蓋了編寫代碼的全流程,比如 VSCode 文檔中列出的這些(其實並沒有列完):

我們要做的就是實現這些 API 的具體功能(下文會講一下實現各種能力時要用到的東西,比如解析 AST 等等....)

Language Server Protocol

What is this

大概介紹完了 languages.* API,我們來說一下 LSP(Language Server Protocol,語言服務協議),微軟定義了一套 Language Server Protocol,語言服務協議,這個東西其實也是很簡單的一個東西,就是將原來 VSC 裏面的功能抽出來,在單獨的一個程序中實現,實現編輯器、語言、語言服務的解耦合。

原來我們在插件中做的邏輯,現在移到了一個專門的語言服務器中去做,插件負責接受 IDE 和語言服務的中轉。

歷史故事

一個偉大的事物肯定是其來有自的,最開始,OmniSharp 這個 C# 的插件提出了語言服務器的概念,它採用了 HTTP 通信,使用 JSON 作爲交換數據格式,並且成功的集成到了 VSCode 等多個編輯器中。大概在同一時間,爲了能支持在多個 IDE 中使用插件,微軟開始爲 TypeScript 編寫語言服務器,使用了 stdin/stdout 的通信方式,也使用了 JSON 作爲交換數據格式。最後,TypeScript 的語言插件也成功的引入到了 Sublime 和 VSC 中。

VSCode 團隊在引入了這兩種不同的語言服務器後,決定要探索一種新的通用的語言服務協議,能讓不同的 IDE 消費同樣的語言服務。對於不同的 IDE 只需要實現一次協議就行,對於不同的語言也只要實現一次語言服務即可。

他們以 TypeScript 的語言服務器爲藍本,並且參考了 VSCode 的 languages API,提出了語言服務協議的概念,可以覆蓋一般編程語言中的 補全 / 診斷 / 定義 / 類型 功能。

How

Language Server Protocol 約定了語言服務的客戶端和服務端使用 JSON-RPC 進行通信,一個請求格式就長這樣:

Content-Length: ...\r\n
\r\n
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "textDocument/didOpen",
  "params": {
    ...
  }
}

其中的 method 就是用戶觸發的一次事件,比如還有:

定義了各種接口格式,比如如何代表一個某個位置:

interface Position {
  /**
   * 位置在代碼文件中的第幾行
   */
  line: uinteger;
  /**
   * 代碼在該行的第幾個字符(指該字符之後的位置)
   */
  character: uinteger;
}

LSP 下的通信流程大概就長圖裏這樣:

圖源

https://microsoft.github.io/language-server-protocol/overviews/lsp/overview/

一般來說 Client 與 IDE 層連接着,接受 IDE 層發出的用戶事件,然後根據這個事件類型請求 Server 相應的接口,Server 接收到請求後分析用戶的代碼,然後將符合 Language Server Protocol 的響應返回給 Client,Client 接收到響應之後將信息返回給 IDE,IDE 再展示出相應規範的組件。

原來的 VSCode languages.* API 在 LSP 中大概都有實現。

優勢

原來不同的編輯器對一門相同的語言要單獨實現一份自己的代碼,N 種編程語言,M 個 IDE,那就要寫 N * M 份代碼,現在有了一層中間的抽象,就只需要寫 N + M 份代碼就好。

圖源:Language Server Extension Guide - VSCode

(https://code.visualstudio.com/api/language-extensions/language-server-extension-guide)

比如說原來想在 VSCode 上寫一個語言插件,那就只能用 Node.js 重新寫一遍靜態分析工具。而現在你可以用自身這門編程語言,可以很大程度上利用自身的生態。

圖源:microsoft/vscode-docs - GitHub

(https://github.com/microsoft/vscode-docs/blob/5112c1b325da0dd942e278329024c342038184b7/docs/extensions/images/overview/extensibility-architecture.png)

還有一個好處是語言服務器運行在一個單獨的進程中,這也帶來一些好處:

  1. Language Server 屬於 CPU 密集型程序,爲了正確地驗證一個文件,Language Server 需要解析大量的文件,爲它們構建抽象語法樹,並執行靜態程序分析。在 VSCode 的插件進程模型中,每個窗口的所有插件共享一個 Extension Host,如果語言服務卡住的話,所有的插件都會受到影響。在獨有進程中運行語言服務器可以避免與單進程模型相關的性能問題。

  2. 我們可以在插件進程中隨時重啓語言服務進程,語言服務卡住了就重啓單個進程。跟原來重啓 VSC 相比帶來了一些微小的使用體驗優化。

由於各個編程語言上對 LSP 基本都有 Server 實現了,所以我們的編寫體驗還是和上面直接調用 VSCode API 差不多。

中場休息

接下來就是真正的乾貨部分了,會重點介紹語言服務的幾點如何實現:

  1. 深入理解代碼高亮

看完就能自己寫一個主題插件了。

  1. 通過編程實現一些語言特性

看完就能自己動手寫一個小玩具了。

  1. 值得介紹的其他的一些小特性

最後介紹一下大家對語言插件感興趣的幾個點。

代碼高亮

VSCode 有兩種方法進行代碼高亮,語法和詞法,詞法高亮就是根據詞法規則來定義高亮。而語法高亮允許我們編寫程序來分析出 token,獲得更精準的高亮顯示。我們這裏只講詞法高亮,在最後會簡單提一下語法高亮。

VSCode 的代碼高亮分爲兩步:

  1. 符號化(Tokenization):使用 TextMate language grammars(語言語法) 來進行詞法解析。

TextMate language grammars 是由 TextMate Editor 採用的分詞語法,然後被衆多編輯器接受並支持,並且社區開發了各種語言的語法文件。

language grammars 用於爲代碼中的元素(如關鍵字、註釋、字符串等)分配名稱。這樣做的目的是允許我們根據編寫名稱的選擇器進行樣式化(語法高亮展示)。

  1. 樣式化(Theming):使用 scope selector(範圍選擇器) 來選取第一步分析出的特定元素,然後爲它們指定樣式。

上一步的 language grammars 只是用來爲文檔元素指定名稱,這一步我們需要使用 scope selector 選擇特定元素(就像使用 CSS 選擇器選擇 HTML 元素一樣)。然後我們可以指定選定元素的字體樣式、字體顏色等。

對於前端同學而言,理解這兩步就像喝水一樣簡單,因爲其實就是在 HTML 中這樣:

  1. 爲 DOM 樹的某一個節點標記 className。

  2. 通過 CSS 選擇器選定節點,然後爲其配置樣式文件。

圖中就很形象的展示了這兩步的信息,textmate scopes 中是詞法分析出的該元素的名稱,然後下面的 foreground 展示的是通過 entity.name 設置的一個樣式:{ "foreground": "#6F42C1" }

在 VSCode 中,輸入命令 Developer: Inspect Editor Tokens and Scopes 即可喚起該調試頁面。

符號化(Tokenization)

就是詞法分析。通過編寫 grammars 來爲文檔元素(比如關鍵字、註釋、字符串等)分配一個作用域名稱(scope),然後我們可以根據不同的名稱進行樣式化(設置顏色,字符樣式)。

“as you walk through the text, look for this pattern and assign this scope.”

我們來展示一個簡單的例子:

    "keywords": {
      "patterns": [
        {
          "name": "keyword.control.test",
          "match": "\\b(if|else|while|for|return)\\b"
        }
      ]
    },

這是一個最基本的 pattern,鍵爲 keywords,代表這條規則的名稱;值的對象中有個 patterns,我們要在裏面寫規則,規則裏只有一個 match 字段,match 字段裏是一個正則表達式,對於匹配到 match 規則的文本都標記爲 name:keyword.control.test

樣式化後就是這種效果:

我們把爲元素分配的這個 name 叫做作用域(scope),我們在接下來的樣式化環節可以使用**作用域選擇器(scope selector)**來選擇不同的元素。

scope 是一種用 . 來分級的文本結構,比如 a.ba.b.c 是父子關係,使用選擇器 a.b 可以選擇到 a.b.c

從設計上來說你可以設置任意文字爲 scope,如 aaa.bbb.ccc。但是從方便編寫着色規則來說,官方還是推薦了一套命名約定:

按照這個規則,我們可以把 C 語言中的雙引號字符串的 scope 設爲 string.quoted.double.c,TypeScript 中的單引號字符串設置爲 string.quoted.single.ts,然後我們只需要設置一個選擇器 string.quoted 的樣式即可。

language grammars 還允許我們使用更多精準的語法規則,如:

    "strings": {
      "name": "string.quoted.double.test",
      "begin": "\"",
      "end": "\"",
      "patterns": [
        {
          "name": "constant.character.escape.test",
          "match": "\\\\."
        }
      ]
    }

可以看到這裏出現了兩個正則表達式,beginend,在被 beginend 之間匹配的所有內容都被標記爲 name(也包括了 beginend 本身的內容)。如果 begin 已經匹配了,沒有匹配到 end,則會將文本結束當成 end。在這種模式下,還可以再設置開始和結束之間內容的子 pattern。

樣式化效果如下:

此外還有一些其他的語法字段,這裏簡單列舉一下:

  1. contentName 與 name 差不多,但是隻意味着 begin 和 end 之間的內容,開區間。

  2. captures, beginCaptures, endCaptures

captures 是一個對象,鍵代表了 match 的內容的組,值是賦值的 scope

   "match": "(@selector\\()(.*?)(\\))",
   "captures": {
     "1": { "name": "storage.type.objc" },
     "3": { "name": "storage.type.objc" }
   }

beginCaptureendCapture 就是針對 begin 和 end 裏的內容的分組捕獲。

  1. include 允許引用語法中其他的規則,就可以做到遞歸分析、簡化規則等。

完整的語法規則請見:https://macromates.com/manual/en/language_grammars

最後,在整個文本匹配完後,整個 scope 樹也會構造完成,第二行構造出的 scope 樹大概是:

更復雜的例子可能是這樣:

樣式化(Theming)

在我們對代碼進行好詞法分析後,我們就可以設置相應 scope 的樣式了。可以設置字符顏色,樣式等。

這個其實就是主題插件來做的事情,根據配色,對常見的 scope 設置相應顏色。

我們可以通過 VSCode 提供的 editor.tokenColorCustomizations 來體驗一下修改 token 顏色:

你可以通過命令 Developer: Inspect Editor Tokens and Scopes 來查看編輯器中某一個元素的 scope 信息,展示信息中的 textmate scopes 是按樹的層級展示的,越前面的代表越深,越後面的代表層級更淺。當我們爲一個 scope 設置着色後,具有該父作用域的所有 scope 都會被着色,如果有設置更具體的着色規則,則會被應用。

將 JSON 的 property 都設置爲紅色

將 json 中的 string 都設置爲粗體,粉色;由於 property 的顏色被其他更細緻的選擇器覆蓋了,所以並沒有展示粉色:

可以看到,在寫好 scope 選擇器之後,就可以設置相應 scope 的樣式了,可以設置前景色,字符樣式(bold, italic, underline)。

      {
        "scope": "source.json string",
        "settings": {
          "foreground": "#FF0000",
          "fontStyle": "bold"
        }
      }

這裏簡單說一下 scope selector 的幾個基本規則:

  1. scope selector 是前綴匹配的,string 可以匹配到 string.quoted, string.quoted 可以匹配到 string.quoted.double

  2. 空的 scope selector 可以匹配到任意的 scope,但是優先級最低。

  3. 子級選擇器:以空格爲分割,按照 scope 樹的層級順序描述。

  4. 同級選擇器:使用 , 分割。

  5. 選擇器排序規則:會使用最棒的(層級最深,描述最細緻)一個匹配。

更詳細的規則見:https://macromates.com/manual/en/scope_selectors

延伸閱讀:

詞法分析 VS 語法分析

在 VSC 1.43 之後,我們也可以通過新 API(Semantic Token Provider)來編程定義語法高亮,更精準。

比如說:

圖源:Semantic Highlight Guide - VSCode

(https://code.visualstudio.com/api/language-extensions/semantic-highlight-guide)

可以看到語法分析的第 10 行的 langaugeModes 的顏色和參數聲明的顏色是一樣的了,而詞法分析的結果卻只是一個屬性值。13 行的 getFoldingRanges 的顏色被設置成了函數而非屬性,更 make sense。

要做語法分析,我們就需要解析代碼的 AST 來獲取每個 token 的準確意義,具體在 VSC 中的配置也與詞法分析有所不同,可以參考文檔:https://code.visualstudio.com/api/language-extensions/semantic-highlight-guide

Snippet

snippets 也是我們在使用 VSC 時會經常接觸到的一個東西,也是一個加速代碼開發的好東西。至少嘛,能讓你打日誌的時候比別人快一點。

舉個例子:

snippet 也是通過編寫配置文件來定義的,大概內容就是這樣:

{
  "Print to console": {
    "prefix": "log",
    "body": [
      "console.log('$1');",
      "$2"
    ],
    "description": "Log output to console"
  }
}

參考:

https://code.visualstudio.com/docs/editor/userdefinedsnippets#_creating-your-own-snippets

編程式語言特性

本部分內容只放具體邏輯代碼,所有代碼見:https://github.com/lengthmin/vscode-npm-enhanced。

我們通過編寫一個增強 npm package.json 的插件來展示幾個 languages.* API 的用法。VSCode 的 API 之多,一篇文章放不下。

需求

  1. 我們希望能在 package.json 中點擊 dependencies 或者其他依賴聲明中的項,就跳轉到 node_modules 下相應的包的 package.json 中。

  2. 我們希望點擊版本號的時候能直接打開 npmjs.com。

  3. 我們希望鼠標懸浮在 dependecies 上的時候有一些提示。

實現

1、跳轉到依賴的 package.json 文件

要實現第一點,我們可以使用 VSCode 提供的 documentLink 能力,該能力可以給代碼文本中的某一段加上超鏈(鏈接可爲網址,文件地址等),用戶可點擊這部分內容然後打開該鏈接。

vscode.languages.registerDocumentLinkProvider 的函數簽名見:

registerDocumentLinkProvider(selector: DocumentSelector, provider: DocumentLinkProvider): Disposable

傳入的第一個參數是文檔選擇器 (DocumentSelector),第二個參數 DocumentLinkProvider 可以認爲是一個回調接口,我們通過實現該接口的方法(實現接口定義的返回值)。當 VSCode 打開了命中文檔選擇器的文檔時,你編寫的代碼會被執行。

可以來看一下 DocumentLinkProvider 接口:

interface DocumentLinkProvider<T extends DocumentLink = DocumentLink> {
  provideDocumentLinks(document: TextDocument, token: CancellationToken): ProviderResult<T[]>;
  resolveDocumentLink(link: T, token: CancellationToken): ProviderResult<T>;
}
class DocumentLink {
  range: Range;
  target?: Uri;
  tooltip?: string;
}

我們一般只需要實現 provideDocumentLinks 方法即可,該方法返回一個 DocumentLink 數組,DocumentLink 記錄了一處要標記爲超鏈接的位置信息(range)和鏈接信息 (target),VSCode 獲取了這個信息後將它們渲染出來,然後用戶就可以點擊了。

首先,我們僅僅需要在 package.json 這個文件裏進行 documentLink,我們的 DocumentSelector 可以寫成:

  vscode.languages.registerDocumentLinkProvider(
    {
      scheme: "file",
      pattern: "**/package.json",
    },
    {
      provideDocumentLinks(document) {
        // ...
      },
    }
  );

然後我們來補全 provideDocumentLinks 的邏輯:

我們需要獲取該文件的內容,然後將內容解析爲抽象語法樹(Abstract Syntax Tree,AST),VSCode 提供了一個 vscode-json-languageservice 幫我們做這件事情。

在這裏可以引申出一個問題:

『我們不可以通過 JSON.parse 來將文件內容轉爲一個對象,然後直接通過鍵來獲取值以完成我的功能嗎?』

確實,如果我們想實現一些簡單的功能,不需要知道用戶光標位置,不需要涉及到某個鍵值在文檔中的位置的時候,可以直接通過解析文本成對象來完成相應功能。

但是如果我們想知道用戶光標處的信息是什麼,是一個鍵,還是一個值,它的值又是什麼。單憑一個 JSON 對象我們很難獲取光標處的整體內容。所以這個時候我們就需要 AST,AST 上的每個節點都代表 JSON 對象中的一塊結構,會攜帶該結構的起止位置,類型等。

可以通過 https://astexplorer.net/ 來查看一下 ast 的例子,我們之後使用的 vscode-json-languageservice 生成的 AST 與例子不太一樣,但大同小異。

一個抽象語法樹的例子

接下來我們就要解析用戶的 package.json 文件,provideDocumentLink 有一個參數是 document,該參數有用戶打開的文件 URI 地址,還封裝了諸如 getText, positionAt 等方法方便使用。

import { getLanguageService } from "vscode-json-languageservice";
const jsonLanguageService = getLanguageService({});
// ...
      provideDocumentLinks(document) {
        const result: vscode.DocumentLink[] = [];
        const jsonDocument = jsonLanguageService.parseJSONDocument(document);
        result.push(
          ...doDependencyLink(jsonDocument, document, "dependencies"),
          ...doDependencyLink(jsonDocument, document, "devDependencies")
        );
        return result;
      },
// ...

如代碼所示,我們可以通過 getLanguageService({}).parseJSONDocument(document) 獲取該 JSON 的 AST;我們需要遍歷這棵抽象語法樹,找到 dependencies 和 devDependencies 節點,然後獲取該節點下每個依賴的名稱、在文本中的起止位置,然後構造要跳轉到的文件的 URI,我們就以最簡單的方式來構造這個地址:當前 package.json 同級目錄下的 node_modules 下的對應依賴的 package.json

這樣我們就實現了將 package.json 文件中的所有依賴的名字都加上了超鏈接,用戶點擊某個依賴的名字時,跳轉到 node_modules 下對應的包的 package.json 中。

比如說如何找到 dependencies 的節點:

  const result: vscode.DocumentLink[] = [];
  const dependencies = jsonDocument.root?.children?.find((child) => {
    if (child.type === "property" && child.keyNode.value === field) {
      return true;
    }
  });
  if (!dependencies) {
    return result;
  }

通過遍歷抽象語法樹的第一層子節點,找到一個 property 節點且這個 property 節點的鍵爲 dependencies。

export interface PropertyASTNode extends BaseASTNode {
    readonly type: 'property';
    readonly keyNode: StringASTNode;
    readonly valueNode?: ASTNode;
    readonly colonOffset?: number;
}

這是 VSCode 定義的 PropertyASTNode,顧名思義,就是一個鍵值對屬性的 AST。其中 keyNode 就是鍵,valueNode 就是值。因爲我們知道 dependencies 的值也是一個對象,所以我們要判斷一下 valueNode 的類型是不是 ObjectASTNode:

export interface ObjectASTNode extends BaseASTNode {
    readonly type: 'object';
    readonly properties: PropertyASTNode[];
}

當我們拿到了 dependencies 的值並且它是一個 ObjectASTNode 的時候,我們就可以遍歷它的 properties 屬性,這個列表的每一項都是 PropertyASTNode,它的鍵就是我們需要的依賴名稱,值就是該依賴的版本。

 const _valueNode = (dependencies as PropertyASTNode).valueNode;
  if (_valueNode?.type === "object") {
    _valueNode.properties.forEach((child) => {
      result.push({
        range: new vscode.Range(
          document.positionAt(child.keyNode.offset),
          document.positionAt(child.keyNode.offset + child.keyNode.length)
        ),
        target: vscode.Uri.parse(
          path.join(
            document.uri.path,
            "..",
            "node_modules",
            child.keyNode.value,
            "package.json"
          )
        ),
      });
    });
  }

然後我們就生成每一項的鍵的位置範圍(將鍵在文本中的偏移值轉換成 DocumentLink 需要的 Position 類型,也就是轉換成第 n 行,第 m 列的格式),根據依賴名稱構造一個目標文件的地址,這樣我們的第一個功能就完成啦~

2、跳轉到 npmjs.com

然後想實現第二個功能也非常簡單,就是取 dependencies 的每一項的值節點,然後做一樣的事情就可以了:

      if (child.valueNode) {
        result.push({
          range: new vscode.Range(
            document.positionAt(child.valueNode.offset),
            document.positionAt(child.valueNode.offset + child.valueNode.length)
          ),
          target: vscode.Uri.parse(
            `https://npmjs.com/package/${child.keyNode.value}`
          ),
        });
      }

獲取 valueNode 的偏移、值,拼接成要打開 npm 網站的鏈接地址。

3、dependencies 懸浮提示

需求的第三點是 希望鼠標懸浮在 dependecies 上的時候有一些提示,這個功能可以通過 vscode.languages.registerHoverProvider 來實現:

vscode.languages.registerHoverProvider(
    { scheme: "file", pattern: "**/package.json" },
    {
      async provideHover(document, position, token) {
        const jsonDoc = jsonLanguageService.parseJSONDocument(document);
        const node = jsonDoc.getNodeFromOffset(document.offsetAt(position));
        let tmpNode = node?.parent;
        let depType = "";
        if (
          tmpNode &&
          tmpNode.type === "property" &&
          tmpNode.keyNode.type === "string" &&
          (tmpNode.keyNode.value === "dependencies" ||
            tmpNode.keyNode.value === "devDependencies")
        ) {
          depType = tmpNode.keyNode.value;
        }
        if (depType) {
          return new vscode.Hover(
            `當前位置是 ${depType}, value: ${node?.value}`
          );
        }
      },
    }
  );

我們註冊一個 providerHover 的回調,當用戶懸浮在某處時,我們能獲取到用戶的光標位置,然後調用 vscode-json-languageservice 來解析當前 JSON 文件的 AST,然後調用 JSONDocument.getNodeFromOffset 來獲取指定位置的 AST 節點,該函數內部的實現就是遞歸查找數的子節點,然後找到某個節點,它的 offset 小於等於的我們傳入的 offset。

找到我們需要的節點後,就可以讀取它的值,然後拼接成我們想展示的一句話即可。

我們以兩個 vscode.languages.* 的 API 來介紹了 VSCode 的插件機制,對於其他的 API 你可以按照一樣的流程來實現。

Language Server Protocol

按照我們前面所講的,Language Server Protocol 約定了一層中間抽象,在 LSP 的定義中,一個完整的 Language Server 分爲了兩部分:Client 和 Server:

顧名思義,Client 就是連接着 IDE / 編輯器 的這部分,在 VSCode 中就是一個插件,該插件會處理各種 VSCode 的事件,然後向 Server 發送符合 LSP 的請求,在接收到響應後,再轉爲 IDE 的組件 / 動作等。

Server 就負責接收請求,做一些代碼的靜態分析,各種運算等,然後將符合 LSP 的響應返回即可。

一般在開發過程中,我們都會選擇封裝好的 client/server 的 SDK,使用 client SDK,它幫我們隱藏了與編輯器交互的細節,使用 server SDK,它隱藏了與 client 交互的細節,我們只需要專注於業務開發即可。

你可以在這(https://microsoft.github.io/language-server-protocol/implementors/sdks/)查看社區上的 SDK,微軟官方維護了一份 Node.js 的 Language Server SDK:https://github.com/microsoft/vscode-languageserver-node。

這裏我們簡單演示一下 Node.js 的基於 vscode-languageclient 的 Client 端和基於 vscode-languageserver 的 Server 端的代碼,以下代碼來自 VSCode 插件示例:

Client 端是一個 VSCode 插件,它會在自己被激活的時候新啓動打包後的 Server 端的代碼:

import {
  LanguageClient,
  LanguageClientOptions,
  ServerOptions,
  TransportKind
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: ExtensionContext) {
  let serverModule = context.asAbsolutePath(
    path.join('server', 'out', 'server.js')
  );
  let serverOptions: ServerOptions = {
    run: { module: serverModule, transport: TransportKind.ipc },
    // ...
  };
  let clientOptions: LanguageClientOptions = {
    // ...
  };
  client = new LanguageClient(
    'languageServerExample',
    'Language Server Example',
    serverOptions,
    clientOptions
  );
  client.start();
}

我們需要設置 Client 端的一些配置,比如 encoding,輸出,文件選擇器等,還需要設置 Server 的啓動方式、通信方式、通信配置等等等等,然後 Client 端就可以根據我們的配置連接上 Server 端了。

連接之後雙方會交換一些初始信息,比如當前 Client 支持什麼能力,Server 支持什麼能力,初始化後整個語言插件就可用了。

我們再來看一下 Server 端,

import {
  createConnection,
  InitializeParams,
} from 'vscode-languageserver/node';
const connection = createConnection(ProposedFeatures.all);
connection.onInitialize((params: InitializeParams) => {
  // 讀取客戶端能力,返回自己的能力
  const capabilities = params.capabilities;
  return {
    // ...
  };
}
connection.onInitialized(() => {
  // 初始化完成
});
connection.onDidChangeConfiguration(change => {
  // 當客戶端改變了配置
});
connection.onCompletion(
  (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
    // 客戶端發起補全請求
  }
);
// ...
// 在 connection 上掛上各種語言特性的回調
connection.listen();

vscode-languageserver 幫我們封裝了各種與客戶端的交互,我們只需要建立一個連接,然後在其上加入各種語言特性的回調,最後調用一下 listen 監聽請求,服務端也就啓動成功了。

我們需要在這些回調裏編寫具體的代碼完善各種特性。

也許是一些答疑解惑

1、如何做到 HTML 中的補全?

首先我們知道用戶觸發補全事件的位置,然後遍歷 AST,找到用戶光標位置所屬的 AST Node(一般這一步需要你結合編譯器等各種工具來分析),然後我們根據掃描結果,標記用戶的狀態,比如可能是

  1. 輸入標籤名

  2. 輸入標籤屬性

  3. 輸入標籤值

  4. ...

我們根據這些不同的狀態來進行不同的補全。

2、多語言服務

我們都知道 Vue 是一個結合了多種語言的語言服務,一個 .vue 文件中,我們需要實現 JS/TS/HTML/CSS 多種語言服務,所以我們需要抽象出一箇中間層:

該層知道用戶請求當前打開的文件屬於哪個語言服務,並且調用該語言服務的功能。

結語

我們大概介紹了一下 VSCode 給我們提供的語言能力以及各種能力該如何實現,也簡單介紹了一些例子。

實現一門新語言的靜態分析不是一件簡單的事,

VSCode 官方維護了很多的語言插件,都在 https://github.com/microsoft/vscode/tree/main/extensions 這個倉庫中,你可以查看他們的源碼來進一步學習。

參考鏈接

代碼高亮部分的參考:

  1. Syntax Highlight Guide | VSCode

  2. https://code.visualstudio.com/api/language-extensions/syntax-highlight-guide

  3. Writing a TextMate Grammar: Some Lessons Learned

  4. https://www.apeth.com/nonblog/stories/textmatebundle.html

  5. 你不知道的 VSCode 代碼高亮原理 - 範文傑

  6. https://segmentfault.com/a/1190000040211606

  7. Language Grammars | TextMate

  8. https://macromates.com/manual/en/language_grammars

編程式語言特性的參考:

  1. Language Server Protocol Specification

  2. https://microsoft.github.io/language-server-protocol/specifications/specification-current/

  3. 如何開發一款 VS Code 語言插件 — 以 vetur 爲例

  4. https://www.bilibili.com/video/BV1sh411z7Vq

關注「Alibaba F2E」微信公衆號把握阿里巴巴前端新動向

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Eqb9hAvG-WeseP9rCl_XRg