代碼編輯器系列 #1 架構與解耦
來自專欄 千里冰封被吊打的日常
在上一篇文章中我提到了兩種代碼編輯器—— Vim/VSCode 類和 IntelliJ IDEA/Visual Studio 類。 這兩者分別是把代碼分析的任務交給另一個程序,和自主進行代碼分析。 微軟已經為前者(VSCode)的這種策略所採用的協議起了一個名字,叫 Language Server Protocol,下文使用簡稱『LSP』。 然後自主代碼分析的由於 JB 家的都屬於標準的案例,就使用簡稱『JB』(和 VS 是一樣的)。
代碼編輯器一般都會考慮一個問題,即藉助插件添加新語言支持(這裡還涉及插件系統的設計,不過這暫時不是本文討論的重點)。 顯而易見的是,這裡有一個代碼分析器和編輯器前端的解耦問題。 編輯器開發者需要首先對代碼進行抽象,把『代碼分析』(本質就是一個 [Library] -> Code -> ([Diagnostic], [QuickFix], [Highlight])
的函數)交給插件開發者(以及內建語言支持),插件開發者進行代碼分析,反饋分析結果,再由編輯器前端顯示分析結果、代碼高亮、提供 quick fix。
然後還可能有其他設施,比如往歡迎界面加東西,或者改右鍵菜單之類的雜七雜八的不難實現的特性。 這些東西就不說了,完全不難。
名詞解釋
上面提到了代碼編輯器的兩種解耦模式,分別是『編輯器只負責編輯,後台進程才看得懂代碼語義』的可以不讀完文件的 LSP 式,和『編輯器搞定一切』的必須讀完文件的 JB 式。
這兩種模式的定義是有絕對的界限的,但現在很多編輯器都可以說是同時具有兩種特點(比如 Emacs 也有用編譯器 server 的(idris-mode
, agda2-mode
, coq-mode
), IDEA 也有使用編譯器 server 的插件),因此使用『是否自主進行代碼分析』來定義是不準確的,目前我想到的一個比較好的區分點就是『是否可以不讀完文件』。
這是因為,JB 式的編輯器顯而易見可以使用 LSP (作為一個額外的代碼分析器),而 LSP 式的編輯器則因為無法提供完整的代碼而無法自主進行語義分析,所以我才使用『是否可以不讀完文件』來區分二者,只是為了防止自己造出類似『函數式編程』這樣的名詞而已。
兩種分析策略
LSP 和 JB 式分別將『代碼』這一概念進行了兩個不同層面的抽象。
根據上面給的 LSP 的 overview,可以看出 LSP 是通過給 language server 發送編輯造成的增量改動(range
或者offset, text
,分別對應插入和刪除嘛)配合 language server 的增量 parsing 來實現更新的。 至於高亮(tokenize 同),要麼從 language server 獲取,要麼用正則表達式或者 lexer。 可以看出,編輯器只知道插入和刪除的 range ,代碼對它來說只是一個一個的 token。
而 JB 式的編輯器會使用自己內置的 parser 對 AST 進行更新(可以做成增量的),代碼對它來說是 AST ,可以拿到元素的父子節點,知道代碼的層級嵌套關係以及詳細的語義。 如果能拿到完整的 code base (大部分情況下都能拿到,但也有特例,比如根據 vczh 的說法 office 的開發就拿不到,因為代碼太多了。這種代碼也不適合使用對代碼進行完整分析的 IDE 開發,一般都是編輯器家族或者大型 IDE 關掉一些分析功能來寫),就可以對代碼進行完整的靜態分析,比如 IDEA 可以根據函數里對參數的處理,推導庫函數參數的 @NotNull
和 @Nullable
(只要直接調用了上面的函數,或者傳給了需要 @NotNull
的函數,那麼就是 @NotNull
;如果有什麼 if (參數 == null) return 默認值
的語句,就會推斷為 @Nullable
,從而對錯誤的操作(比如傳一個 null
給一個直接在這個參數上調用方法的函數,很明顯的 NullPointerException
)進行報警),尋找命令行報錯信息中的文件名和行號進行並提供跳轉到出錯地點等。
LSP 的優點是,編輯器和代碼分析是完全分離的, parsing 可以直接復用編譯器(然後還需要實現增量更新 AST),而且可以輕易地保證 parsing 和編譯器的行為完全一致。 不過,任何涉及語義的操作都必須傳 json,遇到 parser 很慢的語言或者大量使用編譯期計算的語言可能會把 language server 卡掉導致語義分析不可用。 重構功能也難以提供,因為涉及大量的 AST 操作。 而內存效率本身也不高,畢竟編輯器本身和 language server 都在讀取代碼、分析代碼,保存了不必要的多份拷貝。
JB 系的優點是,插件可以輕鬆進行自主代碼分析和重構。AST 是以結構化數據存在於內存中,讀取成本很低。 比如,expansion selection(IDEA 默認快捷鍵 Ctrl+W)這樣的操作實際上只是需要取 AST 的父子節點,而這個操作一般都是會很快地連續多次進行的,在 LSP 式編輯器里就需要不停地收發 json,而 JB 就只是調用一個函數,訪問一個變數而已。 在不編譯的時候,在運行的程序就只有編輯器,代碼只存在於編輯器里,沒有多份拷貝。 遇到語法複雜的語言時,可以選擇寫一個輕量級的 parser ,把一些原本屬於語法分析的工作放到後台的代碼分析進程里,可以在一定程度上緩解編輯時的體驗(其實就是自己實現 parser 帶來的靈活性)。 不過,要實現這樣的功能,就要犧牲部分讀取文件的優化,以及需要重新實現一個該語言的 parser ,行為很可能和編譯器本身是 parser 不一致,帶來了坑的可能性。兩個承擔相同任務的 parser 本身也是不應該同時存在的,這是不優雅的架構。
兩種解耦策略
LSP 眼中的代碼,是一個線性的 token 序列。JB 眼中的代碼,是一個樹形的語法結構。 一個把全部代碼分析交給插件,一個只把語法定義交給插件。
這就是見仁見智的兩種解耦方式了。最優的是 parser 和 IDE 本身就使用同一個分析框架的語言,他們不僅只有一份 code base,而且實現了編譯器和 IDE 行為一致,重構的時候也不需要進行高成本的通信,甚至編譯的時候可以直接使用 IDE 拿到的 AST。 這類例子有很多, Roslyn + Visual Studio 是一種, Kotlin + IntelliJ IDEA 是一種,其他的我還沒聽說過不過不可能只有這倆。 由於社區的分離,絕大多數語言都不可能有這種待遇(只有微軟和 JB 才出 IDE,其他搞語言的只做的起插件)。 不過我以後要是造語言,肯定會提供這樣的套餐的。不然和鹹魚有什麼區別呢。
本文完
機場寫文章真爽。
成都,辣雞二線小城市。真羨慕北京的教育條件,不過我這裡也比山區的孩子好了。 希望我能把這一手不好不爛的牌打好點吧。
這個系列還會更新哦。 以後的方向主要是講 JB 式編輯器的實現,以及一些文本編輯器的通識技巧。
推薦閱讀:
TAG:編程 | 代碼編輯器 | VisualStudioCode |