2023年,nvim以及其生態已經發展的愈來愈完善了。nvim內建的LSP(以及具體的語言服務)加上眾多外掛,可以搭建出支援各種型別語法檢查、程式碼補全、程式碼格式化等功能的IDE。網路上關於如何設定的文章很多,但本人發現絕大多數的文章僅僅停留在設定本身,沒有深入的解釋這些外掛的作用和它們之間的關係,這就導致了很多入門的小夥伴在設定、使用的過程中遇到各種問題也不知如何下手。本文將手把手,一步一步的演進並解釋,幫助小夥伴瞭解這塊的內容。
注意1:本文主要探討nvim關於LSP、null-ls以及程式碼補全內容,不會詳細介紹如何使用外掛系統。
注意2:本文閱讀前需要讀者已經掌握瞭如何使用外掛管理器來安裝外掛並setup外掛設定。
在本文的開始,讓我們先介紹一下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的模型,我們可以將這個過程描述出來:
現在,我們已經瞭解了基於LSP的程式碼分析處理流程,那麼這個語言伺服器在什麼地方呢?首先,不要看到伺服器三個字,就認為它一定是一個在遠端的Web應用服務,語言伺服器一般就是一個軟體程式,只不過它能夠處理專門解析你編寫的程式程式碼,並做出響應。
使用LSP這套體系,有兩個必備步驟:
有些語言伺服器基於js編寫實現,它一般是一個NPM包,我們以npm -g
全域性安裝的形式安裝它(例如TypeScript的語言伺服器的實現typescript-language-server
);有的語言伺服器直接就是可執行程式(例如lua語言伺服器lua-language-server
),我們從網路上下載它存放到電腦上。通常,我們會把它們的可執行檔案路徑加入到環境變數中,以便隨時在命令列中啟動它們。啟動以後,它就在一個程序中默默的的等待著使用者端(也就是編輯器)連結,並在建立連線以後,進行程式碼的分析處理工作。
瞭解了LSP的基本概念以後,接下來我們介紹在nvim中的LSP模組。在nvim 0.5+版本以後,已經內建了語言服務使用者端的介面(Lsp - Neovim docs,注意只是語言服務使用者端部分),比較常用的API:
但需要注意,上述這些都是介面方法,它只是一個封裝後的殼子方法,不具備具體的實現。具體的實現,需要為每一個程式語言單獨設定。也就是說,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外掛來說,使用起來更加複雜。
每當有一個程式語言需要使用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的使用流程都是一樣:
require('lspconfig')
),這個外掛範例可以存取不同程式語言的語言服務使用者端物件(例如上面的 lspconfig['tsserver']
),每一個語言服務使用者端物件都會有setup
方法,我們只需要通過這個方法傳入對該語言的語言服務設定。當然, 如果setup裡面什麼都不傳,它會使用預設設定進行setup。像上面的lspconfig['tsserver']
,它其實就是針對TypeScript程式碼的語言服務設定,預設設定如下:
cmd
代表了在我們機器上安裝的語言伺服器的命令列啟動方式,比如在我們機器上啟動TypeScript的語言服務,則會呼叫命令:typescript-language-server --stdio
。
filetypes
代表了當遇到哪些檔案型別的時候,會讓語言服務建立連線。在本例中,只要你開啟的檔案型別是javascript、typescript等,就會建立編輯器使用者端與語言服務的連線,連線完成以後,就能進行檢視型別定義、格式化等語言處理操作了。
為了真的能啟動語言伺服器,我們按照檔案提到的方式手動安裝TypeScript和lua的語言伺服器。在我的機器上,安裝好以後,能夠通過命令列方式存取得到:
讓我們來梳理下上述demo的現狀:
步驟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內建的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模組、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。
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的型別檢查。然而,上面的程式碼有兩個問題:
var
來宣告一個變數,這已經是不推薦的變數宣告方式了;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源的過程。
這裡面需要解讀幾點:
什麼叫「非LSP源」呢?像是prettier、eslint,它們本身需要對程式程式碼進行結構、型別解析,然而它們又不關注程式碼的型別檢查等,這類就屬於「非LSP源」;
什麼叫「使用純Lua建立、共用和設定LSP源的過程」呢?還記得前面的TS語言服務、lua語言服務嗎,他們都是實現了LSP協定的語言服務,各自分別用js和lua語言編寫的,需要外部程序啟動。而null-ls希望能夠用lua來編寫,構造一個類似支援在nvim內部執行語言服務的框架(雖然目前 prettier、eslint還是外部安裝啟動的