詳解nvim內建LSP體系與基於nvim-cmp的程式碼補全體系

2023-07-12 12:01:07

2023年,nvim以及其生態已經發展的愈來愈完善了。nvim內建的LSP(以及具體的語言服務)加上眾多外掛,可以搭建出支援各種型別語法檢查、程式碼補全、程式碼格式化等功能的IDE。網路上關於如何設定的文章很多,但本人發現絕大多數的文章僅僅停留在設定本身,沒有深入的解釋這些外掛的作用和它們之間的關係,這就導致了很多入門的小夥伴在設定、使用的過程中遇到各種問題也不知如何下手。本文將手把手,一步一步的演進並解釋,幫助小夥伴瞭解這塊的內容。

注意1:本文主要探討nvim關於LSP、null-ls以及程式碼補全內容,不會詳細介紹如何使用外掛系統。

注意2:本文閱讀前需要讀者已經掌握瞭如何使用外掛管理器來安裝外掛並setup外掛設定。

認識LSP

在本文的開始,讓我們先介紹一下LSP(Language Server Protocol,語言服務協定)。當然,網路上有很多詳細的介紹LSP的內容,本文不會深入介紹它的實現機制,僅作為本文的入門的解釋。

簡單來講,該協定定義了兩端:Language Client(語言服務使用者端)和Language Server(語言伺服器端),其核心是將程式碼編輯器文字介面的展示程式碼語言分析(語言支援,自動補全,定義與參照解析等)解耦。通常,我們的文字編輯器就是一個使用者端,而各種語言的解析則會有對應LSP協定實現的伺服器端。

為了讓讀者更加清楚的理解LSP的運作,我們編寫有如下TypeScript程式碼:

// 1. 定義介面
interface User {
  name: string;
}
// 2. 實現介面的物件
const user: User = {
  name: 'hello'
}
// 3. 列印物件的age屬性
console.log(user.age); // error

上述這段程式碼首先定義了一個名為User的介面(interface User),該介面擁有一個欄位name;然後,我們建立了一個基於User介面的user範例;最後,我們列印了user的age屬性。user並不具備age欄位,所以按照嚴格的TypeScript語言規範來講,程式碼編譯肯定會有錯誤:

基於LSP的模型,我們可以將這個過程描述出來:

  1. 在編輯器上寫入上述的TS程式碼;
  2. 編輯器將上述程式碼通過某種通訊協定傳送給TypeScript語言伺服器;
  3. TS語言服務讀取TS程式碼,進行語法檢查,得到了編譯錯誤資訊(包含行列數,基本的建議提示資訊)返回給編輯器;
  4. 編輯器接收到錯誤資訊,通過自己的方式展示在編輯器UI上。

現在,我們已經瞭解了基於LSP的程式碼分析處理流程,那麼這個語言伺服器在什麼地方呢?首先,不要看到伺服器三個字,就認為它一定是一個在遠端的Web應用服務,語言伺服器一般就是一個軟體程式,只不過它能夠處理專門解析你編寫的程式程式碼,並做出響應。

使用LSP這套體系,有兩個必備步驟:

  1. 獲取並安裝語言伺服器程式;
  2. 啟動語言伺服器,讓它處於執行狀態。

有些語言伺服器基於js編寫實現,它一般是一個NPM包,我們以npm -g全域性安裝的形式安裝它(例如TypeScript的語言伺服器的實現typescript-language-server);有的語言伺服器直接就是可執行程式(例如lua語言伺服器lua-language-server),我們從網路上下載它存放到電腦上。通常,我們會把它們的可執行檔案路徑加入到環境變數中,以便隨時在命令列中啟動它們。啟動以後,它就在一個程序中默默的的等待著使用者端(也就是編輯器)連結,並在建立連線以後,進行程式碼的分析處理工作。

nvim中的LSP

瞭解了LSP的基本概念以後,接下來我們介紹在nvim中的LSP模組。在nvim 0.5+版本以後,已經內建了語言服務使用者端的介面(Lsp - Neovim docs注意只是語言服務使用者端部分),比較常用的API:

  • vim.lsp.buf.hover():程式碼的TIPS懸浮展示。
  • vim.lsp.buf.format():程式碼格式化。
  • vim.lsp.buf.references():當前程式碼符號的參照查詢。
  • vim.lsp.buf.implementation():當前程式碼(主要是函數方法)的實現定位。
  • vim.lsp.buf.code_action():當前程式碼的一些優化操作。

但需要注意,上述這些都是介面方法,它只是一個封裝後的殼子方法,不具備具體的實現。具體的實現,需要為每一個程式語言單獨設定。也就是說,nvim內建的lsp模組的執行架構如下:

面對不同的語言,我們按照對應的語言服務的要求來設定nvim的內建LSP模組。在官方的檔案中給瞭如下的範例來啟動一個LSP:

vim.lsp.start({
  name = 'my-server-name',
  cmd = {'name-of-language-server-executable'},
  root_dir = vim.fs.dirname(vim.fs.find({'setup.py', 'pyproject.toml'}, { upward = true })[1]),
})

這段程式碼不過多贅述,因為它比起即將介紹的lspconfig外掛來說,使用起來更加複雜。

nvim-lspconfig

每當有一個程式語言需要使用LSP的時候,我們都需要形如上述的nvim原生LSP設定來啟動對應的語言伺服器,同時還需要關注很多細節,譬如,你要手動啟動它等等,這一點從使用者體驗上是比較不友好的。

為了更加方便快速的使用nvim的LSP模組,nvim官方提供了neovim/nvim-lspconfig這個外掛。安裝了這個外掛以後,我們只需要進行少量且易於理解的設定,就能通過這個外掛方便快捷的啟動並使用語言服務了。

nvim-lspconfig通過外掛管理器安裝以後,我們就可以通過require的方式獲取它,並通過它來設定某個程式語言的語言服務使用者端。在lazy.nvim外掛管理器下,設定如下:

本人使用lazy.nvim來管理外掛。上述第一行的"neovim/nvim-lspconfig"代表要安裝該外掛;緊接著的config需要編寫一個函數,代表外掛安裝後的設定階段的自定義執行過程(詳見lazy.nvim的檔案),這個方法在nvim每次啟動後,會被lazy.nvim呼叫,我們一般會在這個config的回撥方法中獲取外掛範例呼叫其相關API進行設定。

無論使用何種外掛管理器,nvim-lspconfig的使用流程都是一樣:

  1. 安裝nvim-lspconfig外掛(通過lazy.nvim、packer等外掛管理器,甚至是純手工安裝);
  2. 在確保該外掛安裝完成後的某個時機,獲取nvim-lspconfig外掛範例(require('lspconfig')),這個外掛範例可以存取不同程式語言的語言服務使用者端物件(例如上面的 lspconfig['tsserver']),每一個語言服務使用者端物件都會有setup方法,我們只需要通過這個方法傳入對該語言的語言服務設定。

當然, 如果setup裡面什麼都不傳,它會使用預設設定進行setup。像上面的lspconfig['tsserver'],它其實就是針對TypeScript程式碼的語言服務設定,預設設定如下:

cmd代表了在我們機器上安裝的語言伺服器的命令列啟動方式,比如在我們機器上啟動TypeScript的語言服務,則會呼叫命令:typescript-language-server --stdio

filetypes代表了當遇到哪些檔案型別的時候,會讓語言服務建立連線。在本例中,只要你開啟的檔案型別是javascript、typescript等,就會建立編輯器使用者端與語言服務的連線,連線完成以後,就能進行檢視型別定義、格式化等語言處理操作了。

為了真的能啟動語言伺服器,我們按照檔案提到的方式手動安裝TypeScript和lua的語言伺服器。在我的機器上,安裝好以後,能夠通過命令列方式存取得到:

讓我們來梳理下上述demo的現狀:

  1. 我們使用了0.5版本以上的nvim,它擁有內建的支援LSP使用者端的模組;
  2. 我們安裝了nvim-lspconfig外掛,並在通過設定,讓它在載入以後,又去setup了TypeScript和lua的語言服務設定;
  3. 我們在電腦上外部安裝了TypeScript和lua的語言伺服器,能夠通過命令列存取到。

步驟1、2保證了我們的nvim具備了成為語言服務使用者端的能力;步驟3保證了我們的電腦環境安裝了所需要的語言伺服器。

此時,當我們開啟一個TS程式碼的時候,命令模式下鍵入LspInfo,就會看到如下的資訊:

彈出資訊告訴我們,有一個tsserver關聯到了當前buffer(也就是這個demo.ts檔案)。另外,在最後一行還能看到nvim-lspconfig顯示了當前已經經過設定的語言服務有前面提到的lua_ls和tsserver。

一個buffer會有多個語言服務的使用者端關聯嗎?

當然,比如一個檔案裡面既有TypeScript程式碼,又有css module(import styles from './index.module.css'),當我們把cssmodules的語言伺服器設定進來時候,這份js檔案開啟的時候,就會同時被兩個語言服務使用者端關聯,由兩個語言伺服器分析當前的程式碼內容了。

同時,我們可以測試一下LSP功能。譬如,將遊標移動到user: User的介面User上時候,在命令模式下輸入lua vim.lsp.buf.hover(),就能出現一個介面描述描述:

亦或是,在錯誤程式碼的地方,呼叫lua vim.lsp.buf.code_action(),來讓語言伺服器給出一定的建議操作:

當然,我們不需要每一次想要使用LSP提供的功能的時候都呼叫命令列方式進行,你可以在setup每一個語言服務之前,新增對事件"LspAttach"的回撥,以便在開啟程式碼檔案的時候觸發該回撥,設定對應buffer的keymap。

上面的例子,我們就設定了CTRL+ALT+l(L小寫)鍵來觸發程式碼格式化(format),在我的mac機器上效果如下:

mac機器上CTRL顯示為"^";ALT(meta)鍵顯示為"⌥"。

至於其它的LSP的介面API,例如檢視型別定義、檢視符號在哪裡參照等,我們暫時不進行設定,因為接下來我們將繼續介紹一個在基於nvim內建LSP的介面,各種UI、操作更加優雅現代化的外掛:nvim-lspsaga。

nvim-lspsaga

使用nvim內建的LSP模組的時候,它的UI展示大家可以看到比較簡陋,比如觸發code_action的時候,也是在底端普通文字展示,不夠沉浸式。而nvim-lspsaga這款外掛補齊了nvim原生LSP模組關於使用者體驗的短板。

安裝完成該外掛以後,我們就可以通過Lspsaga暴露出的指令來使用經過Lspsaga封裝的LSP的介面了。例如,在上面的例子中,我們在一段錯誤程式碼上使用命令:lua vim.lsp.buf.code_action(),呼叫nvim內建的LSP的原生的API來獲取程式碼建議操作:

但是,如果我們使用Lspsaga的code_action,就會發現一個非常舒服的UI:

除此之外,還有像是檢視參照:Lspsaga peek_definition等指令供我們使用,這裡就不再演示了,讀者完成設定以後,可以自行測試。

nvim的LSP、lspconfig與lspsaga之間的關係

看到這裡,可能有的小夥伴對目前介紹的nvim內建的LSP模組、nvim-lspconfig與nvim-lspsaga外掛的關係還有些疑惑,這裡我們用一個關係圖做一個簡單的總結:

首先,nvim內建的LSP模組提供了諸如vim.lsp.buf.format()vim.lsp.buf.code_action()等API,只要你設定好了對應程式語言的語言服務模組,那麼呼叫這些指令就能看到效果。

但是,設定語言服務如果僅使用nvim原生的方式是比較複雜的,於是nvim官方提供了一個外掛nvim-lspconfig,來幫助使用者以更加簡單快捷的方式來設定語言服務。

最後,由於nvim內建的LSP模組提供的介面在呼叫後的互動等比較簡陋,於是有了nvim-lspsaga這個外掛,實際上它的底層也是呼叫的nvim內建的vim.lsp相關的介面獲得資料,只是經過封裝以使用者體驗更好的方式展示了出來。

有了上述關係,我們一般都不設定快捷鍵來對映vim.lsp.buf.code_actions()等這些原生API呼叫,而是安裝lspsaga外掛,然後使用經過Lspsaga封裝後的Lspsaga code_action等指令呼叫。

PS:目前似乎lspsaga不支援format(也許我沒找到),只有格式化程式碼還需要使用原生的vim.lsp.buf.format()呼叫,在LspAttach裡面的回撥中繫結keymap。

null-ls.nvim

Github地址:null-ls.nvim

在內建LSP、lspconfig以及lspsaga的加持下,nvim已經具備了支援LSP能力的,且使用者體驗較好的準IDE了。然而,有這樣一個場景還沒有涵蓋到,那就是在語法已經正確的情況下進行程式碼的處理,包括prettier格式化、eslint程式碼處理。具體來講,比如下面這樣一段程式碼:

interface User {
          name: string;
}
var user: User = {
          name: "hello"
}
console.log(user);

上述這段程式碼,從TypeScript語法規範的角度來看是沒有問題的,完全能夠通過TS的型別檢查。然而,上面的程式碼有兩個問題:

  1. 使用var來宣告一個變數,這已經是不推薦的變數宣告方式了;
  2. name欄位的格式化不正確,一般我們使用2個或4個空格來對應一個Tab。

基於上述的問題,不難理解,語言服務通常只專注於程式碼本身的型別檢查、程式碼編譯是否正確,它一般不關注程式碼是否處於最佳實踐,比如程式碼格式規範、程式碼使用規範等。為了補齊這塊,null-ls被推出。該外掛主頁提到了這個外掛創造出來的動機:

Neovim doesn't provide a way for non-LSP sources to hook into its LSP client. null-ls is an attempt to bridge that gap and simplify the process of creating, sharing, and setting up LSP sources using pure Lua.

Neovim沒有提供一種非LSP源連線到其LSP使用者端的方式。null-ls試圖彌合這個差距,簡化使用純Lua建立、共用和設定LSP源的過程。

這裡面需要解讀幾點:

  1. 什麼叫「非LSP源」呢?像是prettier、eslint,它們本身需要對程式程式碼進行結構、型別解析,然而它們又不關注程式碼的型別檢查等,這類就屬於「非LSP源」;

  2. 什麼叫「使用純Lua建立、共用和設定LSP源的過程」呢?還記得前面的TS語言服務、lua語言服務嗎,他們都是實現了LSP協定的語言服務,各自分別用js和lua語言編寫的,需要外部程序啟動。而null-ls希望能夠用lua來編寫,構造一個類似支援在nvim內部執行語言服務的框架(雖然目前 prettier、eslint還是外部安裝啟動的