身為一位使用 Neovim 寫 Clojure 軟體工程師,看著自己同事修改 Elisp 寫一些插件,比方說,下一個快速鍵就把編輯器裡的 hiccup 格式的資料轉換成 HTML 格式 ,這個感覺真的很複雜。一方面,我就是受不了 Emacs ,我試了兩三次了,就是學不好它。另一方面,如果要開發 Neovim 插件,只能選擇 Lua 或是 VimScript 的話,也沒有一個是我喜歡的選項。儘管困難重重,我還是好羨慕那些可以用 Lisp 來延展編輯器的同事。
如果可以有某種 Lisp ,讓我可以在 Neovim 上開發自己的插件,那該有多好?有一天,我發現了 Fennel ,它可以編譯成 Lua 語言。太好了,Neovim 還真的有這個 Lisp 選項。然後,問題來了,要入門 Fennel 與 Neovim 的開發,比我想象得困難許多。
先分享一下,我之前是怎麼失敗的。
我閱讀了 aniseed 這個 github repo 的 README,然後,照著影片做一遍。發現好像有一些 Clojure 的函數都還可以用,但是,關鍵的跳轉定義 Conjure 指令不管用。接著,我試著訂出一個看似簡單一點的目標,並且嘗試前進。很快地我發現,遇到問題時,我常常想不出來該怎麼處理。然後,我就把這件事放一邊了。
減少 Runtime 的依賴
重新再學一次 Fennel 起源於一個契機:在使用 Conjure 來開發 ClojureScript 時,我必須要下一個指令 :ConjureShadowSelect [build-id]
才能啟動 ClojureScript 的互動式開發模式 (interactive development),偏偏這個指令我常常忘記。
有一天,我發現了別人似乎也有相同的問題,於是,他提了一個基於 Neovim 自動命令 (auto command) 的解決方案:
" define a function `AutoConjureSelect` to auto select
function! AutoConjureSelect()
let shadow_build=system("ps aux | grep 'shadow-cljs watch' | head -1 | sed -E 's/.*?shadow-cljs watch //' | tr -d '\n'")
let cmd='ConjureShadowSelect ' . shadow_build
execute cmd
endfunction
command! AutoConjureSelect call AutoConjureSelect()
" trigger the function `AutoConjureSelect` whenever you open a cljs file.
autocmd BufReadPost *.cljs :AutoConjureSelect
很可惜,這個解法在我的電腦上不太成功,應該是因為作業系統的差異。所以我自己改了一個 Babashka script 來取代上頭的 system(...)
。
#!/usr/bin/env bb
(require '[clojure.edn :as edn])
(require '[clojure.java.io :as io])
(def shadow-config (edn/read-string (slurp (io/file "shadow-cljs.edn"))))
(def build-ids (map name (keys (:builds shadow-config)))) ;; 轉換成字串
(print (first build-ids))
看著這個簡單的 Babashka script ,挑戰 Fennel 的念頭又重新點燃了起來:一旦可以改成 Fennel 的話,我的編輯器設置就可以完全只依賴於 Neovim runtime ,不用再多依賴一個 Babashka runtime 。
於是,在幾天的掙扎之後,我勉強抵達了彼岸。
我寫了一個 Fennel Script .config/nvim/fnl/auto-conjure.fnl
,程式碼的長度也跟 Babashka 差不多。
(local {: autoload} (require :nfnl.module))
(local a (autoload :nfnl.core))
(local {: decode} (autoload :edn))
(local nvim vim.api)
(fn shadow-cljs-content []
(a.slurp :shadow-cljs.edn))
(fn build-key [tbl]
(a.first (a.keys (a.get tbl :builds))))
(fn shadow_build_id []
(build-key (decode (shadow-cljs-content))))
{: shadow_build_id}
我再將原本的 system(...)
改成 luaeval("require('auto-conjure').shadow_build_id()")
只依賴於 Neovim runtime 的 auto conjure 就成功了。這邊有更完整的程式碼與討論。
該怎麼學習 Fennel 與開發 Neovim 編輯器插件呢?
終於,我現在對於寫 Fennel 有些掌握了。在繞了一些彎路之後,我認為用以下的順序來學還不錯:
花時間閱讀 Fennel 網站 上的文件。 Setup guide, Tutorial, Rationale, Lua primer, Reference, Fennel from Clojure 滿有幫助的。
設置好 Conjure, fennel 的語法突顯 (syntax highlight), S 表達式編輯 (s-expression editing)。
準備一個夠小的題目,這個題目最好小到你可以先用 Babashka 完成一次,然後再用 Fennel 重做一回。此外,你可以先儘量讓東西簡單一點,它不需要是一個嚴格意義上的插件,只要完成一部分的功能即可。你可以只在
.config/nvim/fnl/
資料夾下,生成 fnl 檔,寫完之後,在.config/nvim/init.vim
,用luaeval()
來引入即可。在開發的時候,你很有可能會遭遇到許多問題。透過這個過程,你要設法搞懂:「如何在 Neovim 裡,有效地除錯」。用 Fennel 開發編輯器插件,真正的挑戰不是語法、語意,這些東西都很容易學會;真正的挑戰是 Neovim 這個 runtime ,它是一個特殊的 runtime ,你會需要記憶一些新的指令,才能觀察這個 runtime 裡的狀態。還有,你必須多少搞懂它的基本運作原理,才能做出合理的除錯假設。
什麼是容易繞的彎路呢?
我在這個過程之中遭遇最大的彎路就是想要引用別人的 Neovim 設置、想要一口氣把自己的 Neovim 設置也改成 Fennel 版本的。可能是因為我的電腦有一些設置跟提供 Neovim 設置的人有些許的不同點,總之,引用別人的 Neovim 設置總是產生大量的 bugs ,多到我處理不完。
此外,別人安裝的許多 Neovim 插件,我試用了一陣子之後,也還是覺得不合用,這一來一回也浪費了許多時間。最後,我還是保持我原本 VimScript 版本的 Neovim 設置,只是做了一些為 Fennel 開發而做的微調。
aniseed 對 Clojure 開發者很友善,但 nfnl 更符合 Lua 生態
最初,Conjure 的作者 Olical 開發 Conjure 時,最早的版本他是用 Clojure 開發,並且透過 Neovim 的 msgpack RPC 與 Neovim 溝通。後來,他將 Clojure 版本的程式碼改成 Fennel 。然而,原始的 Fennel 並沒有任何類似 Clojure namespace
的語法,也許是因為這個原因,他開發了 aniseed 這個編譯器,並且在 aniseed 裡設計了 module, defn, def
這類的 macro 。
基於 aniseed 來開發 Fennel 的感覺其實滿不賴的,特別是剛從 Clojure 轉換過去的時候。差不多就是把 namespace
改成 module
;namespace
裡的 require
換成在 module
裡的 autoload
;defn
與 def
都可以直接使用。
又過了一陣子, Olical 認為強制讓 Fennel 寫的程式碼去依賴於 aniseed 的一些設計,不太合理。因此他重新設計了新版的 Fennel 編譯器── nfnl ,並且修改了 coding style 。在新的設計裡,要宣告函數要用 fn
;要定義變數要用 local
;不使用 module
,而是要照 Lua 的慣例,在檔案的結尾處傳回一個 table 。新的風格沒有那麼的 Clojurish ,但卻可以更好地融入 Lua 語言的生態系。
以下是一個簡單的比較表:
在這邊有個地方令我覺得頗為困惑,那就是即使是最新版本 (4.53.0
) 的 Conjure ,它的 vim.g.conjure#filetype#fennel
這個變數,仍然是指向 "conjure.client.fennel.aniseed"。後來,我查了一下 release note ,查到了一句話,才解決了我的疑惑。
I'm still working on the new Fennel client that runs through nfnl and supports REPL driven development with pure Fennel (no module or def macros required!) but that's still in the works.
在 Neovim runtime 的除錯經驗
我在學習的過程之中,遇到最大的挑戰,主要還是對 Neovim runtime 的不了解。這個感覺有點像是面對一個黑盒子,發生我預想之外的事,而我卻無法在第一時間想出合理的方式去處理。這邊舉幾個例子:
插件沒有自動啟用
這一類的問題是最簡單的,查看插件的原始碼,找出全域變數來修改即可。
Fennel 在 Lisp 家族裡是知名度相對低的 Lisp ,所以一些 Lisp 通用的插件,有可能不會自動辨識到這個語言,因而不會自動啟用。Neovim 插件的設計慣例,對於這種問題,通常透過設置全域變數來處理。所以,當你去檢查插件的原始碼之後,通常可以找到一些 g:
開頭的全域變數。
以 guns/vim-sexp
這個插件為例,我就修改一個全域變數,S 表達式編輯功能 (s-expression editing) 就對 Fennel 啟用了。
let g:sexp_filetypes = 'clojure,scheme,lisp,fennel'
在 init.vim 裡不存在的插件卻啟用了
前面提到,我繞了彎路,去引用別人的 Neovim 設置。於是,別人的 init.lua
安裝了數個我不熟的插件。而我後來放棄了 init.lua
,改回我自己的 init.vim
之後,之前透過 init.lua
安裝的插件卻出現了。此外,還有好幾個 Neovim 的行為也受到了影響。
這該怎麼處理呢?我研究了一陣子之後,才發現有一個 Neovim 的 Ex command :scriptnames
,它可以列出當前掛載的所有插件。於是,我刪去了異常掛載的插件之後,一切才恢復正常。
下方是個用來觀察狀態的 Ex command 列表。
nfnl 的自動編譯沒有發生
放在 .config/nvim/fnl
資料夾下的 fnl 檔案,可以透過安裝 aniseed 或是 nfnl 來自動編譯。然而,我遇到了很奇怪的事,如果我用 aniseed 的話,設置之後就可以自動編譯;我改成使用 nfnl 的話,怎麼設置自動編譯就是無法發生。
所以,這個除錯的步驟變成要從以下幾步著手:
檢查 nfnl 這個插件是否有成功地載入? 這一步可以透過 Ex command
:lua print(require('nfnl'))
來檢查,如果得到table: 0x0102671b80
這種結果的話,表示插件有載入成功。沒有載入成功時,就會得到錯誤訊息。手動執行編譯是否會成功?開啟 fnl 檔,執行 Ex command
:NfnlCompileFile
。如果成功的話,會得到類似下方的結果:
Compilation complete.
{:destination-path "$path_to_nvim/lua/auto-conjure.lua"
:source-path "$path_to_nvim/fnl/auto-conjure.fnl"
:status "ok"}
而如果 .nfnl.fnl
設置錯誤的話,有可能會得到:
Compilation complete.
{:source-path "$path_to_nvim/fnl/auto-conjure.fnl"
:status "path-is-not-in-source-file-patterns"}
結果我做了上述的檢查之後,都沒有問題。那問題自然就縮小到 auto command 的啟動了。於是,我發現我設定的一個自動排版的 auto command 與 nfnl 模組內的 auto command 發生了衝突。
我設置的 auto command 如下:
function! Fnlfmt()
!fnlfmt --fix %
" :e is to force reload the file after it got formatted.
:e
endfunction
autocmd BufWritePost *.fnl call Fnlfmt()
找出問題之後,一切就簡單了。我把 auto command 改成既做自動排版又做編譯。
augroup FennelOnSave
autocmd!
autocmd BufWritePost *.fnl call Fnlfmt() | NfnlCompileFile
augroup END
結語
學習 Fennel 來開發 Neovim 插件,對我而言,不只是學習一門語言或一個開發環境,而是一次重新思考「如何有效地從零開始學習新技術?」的機會。
這段旅程帶來了幾個重要的收穫:
學習策略至關重要──不應一開始就試圖重構整個 Neovim 設定,而應該從一個小而明確的專案入手,逐步學習。理想的專案應該小到可以先用其他語言實作一次。此外,花點耐心閱讀官方文件,往往會有意想不到的幫助。
理解 Neovim runtime 是關鍵── Fennel 的語法本身並不難,真正的挑戰在於如何與 Neovim runtime 互動。掌握觀察 Neovim 內部狀態的方法(例如 Ex commands)至關重要。
標準生態系的價值──在研究過程中,我也順便研究了 Conjure 從 aniseed 轉換到 nfnl 的歷程。這次轉換規模龐大,但 nfnl 的設計讓我相當欣賞,也讓我更理解標準化生態系對於程式碼複用的意義。
對於已經使用 Neovim 和 Conjure 的 Clojure 開發者來說,學習 Fennel 不會是一個太大的門檻。如果你還沒試過,不妨一起來 eat your own dog food —— 味道其實不錯!