写代码的时候,如果能够基于当前的上下文提示补全,不仅能提高写代码的效率,还能提升编程体验,有种行云流水的快感。
Emacs 中之前我用 GNU Global (gtags) 等静态的工具来辅助写代码,最大的问题是无法根据上下文补全,体验不好。现在有了 LSP 协议之后, Emacs 中现在也能实现这个功能了,体验相当不错。
前段时间折腾了一下,在此作个小结。
目前 Emacs 上有两个客户端实现: eglot 和 lsp-mode ,由于 eglot 相对比较简洁,只需要很少的配置,因此我就选它了(暂时还没试过 lsp-mode ,等有需要时再看)。
对于服务端,目前有三个选择 clangd, cquery 和 ccls , ccls 是在 cquery 的基础之上 fork 改进的。 clangd 安装最简单,但功能据说比较弱,我没有试过;一开始我在自己电脑上编译了 ccls ,再把二进制文件拷贝到公司机器,但是补全始终有问题(怀疑必须在使用的机器上编译,直接拷贝行不通),没有找到具体的原因;后来在公司机器上直接编译了 cquery ,可以补全,就没有再折腾 ccls 了。
选定了客户端和服务端之后,接下来就是对具体工程的配置了, cquery 和 ccls 都要求工程根目录有 compilation database 或者 .cquery
/ .ccls
文件。
由于我们的工程都是用 GNU Make 进行构建的,没法使用 cmake
直接生成 compilation database 。其他方案比如 Bear ,由于我们的工具链太老,没有 cmake
无法编译 Bear
,因此也派不上用场。至此由于无法生成服务器的配置文件,看来似乎与 LSP 无缘了。
后来有一天,我突然想到可以直接自己解析 make
的输出(就用 Elisp ),解析 Entering directory
、 Leaving directory
以及 g++
编译的相关行,提取生成为 compile_commands.json
,这样就无须再依赖其他软件。
但是此方法有一个缺点,在新增文件的时候,得人工在 json 文件中增加一条记录,比较麻烦,不易维护。(后来我把这个逻辑实现在一个 web app Compilation Database Generator 中,它可以很方便地解析 GNU Make 输出,得到 json 文件;这样手动维护也不麻烦了。)
其实此种情况下直接用 .cquery
文件最好,无需指定特定的源码文件,只需要配置编译选项以及头文件目录,因此新增文件时不需要修改。举一个 cquery wiki 中的 例子 :
# it will expend to clang/clang++ according to the extension name
%clang
# C specific options
%c -std=gnu11
# C++ specific options
%cpp -std=gnu++14
-pthread
# Includes
-I/work/cquery/third_party
-I/work/cquery/another_third_party
# -I space_is_not_allowed
到此似乎万事俱备,可以愉快地写代码了,最终却发现还有一个问题:由于历史原因,我们的工程源码都是使用 GBK 编码的,但是 LSP 只支持 UTF-8 ,导致由于编码问题无法显示类、函数注释的问题,当时还在 eglot 提了一个 issue (其实不是 eglot 的问题)。
为了解决这个问题想过几种方法:
- cquery 中在输出之前,把内容转为 UTF-8 编码(利用
iconv
库) eglot
在解析服务端返回的数据时,根据指定的编码进行 decode
第一种方法适用面太窄,因为 LSP 的实现还不是非常成熟,有时需要切换到 ccls
来体验,这就意味着得在 ccls
中也用 iconv
再转一道,太麻烦,不符合 DRY
的原则。
第二种方法,需要在 eglot.el
和其依赖的低层通信库 jsonrpc.el
中同时服务端传回数据的编码方式。当时也实现了,后来觉得不是很直接、优雅,就没再继续用了。另外,如果有一天改用 lsp-mode ,那又需要在 lsp-mode 中做一遍类似的修改,同样也不符合 DRY
原则。
最后使用了适配器的方案 Language Server Adapter, or lsa ,使用 Python 3 实现。这样既不动客户端也不动服务端,在他们中间加一层,用于转换服务端的编码。这样就是一个比较通用的方案了,能适配所有的客户端和服务端。
在 eglot 中根据项目的编码情况(通过 .dir-locals.el
区分)决定是否使用此适配器:
(defcustom ccls-init-args nil
"Init args for ccls, e.g. '(:clang (:extraArgs (\"-std=c++03\")))")
(defcustom eglot-ls-output-encoding "utf-8"
"The LS's output encoding")
(defcustom eglot-cpp-ls "cquery"
"The language server for C/C++.")
(defun whatacold/eglot-ccls-contact (interactive-p)
"A contact function to assemble args for ccls.
Argument INTERACTIVE-P indicates where it's called interactively."
(let ((json-object-type 'plist)
(json-array-type 'list)
result)
(cond ((equal "ccls" eglot-cpp-ls)
(push (format "-log-file=/tmp/ccls-%s.log"
(file-name-base
(directory-file-name
(car
(project-roots
(project-current))))))
result)
(when ccls-init-args
(push (format "-init=%s" (json-encode
ccls-init-args))
result))
(push "ccls" result))
((equal "cquery" eglot-cpp-ls)
(setq result (list "cquery" "--log-all-to-stderr")))
(t ; e.g. clangd
(push eglot-cpp-ls result)))
;; apply the adapter if necessary
(unless (equal eglot-ls-output-encoding "utf-8")
(dolist (item (reverse (list "lsa.py"
(concat "--original-response-encoding="
eglot-ls-output-encoding)
"--log-level=DEBUG"
"--")))
(push item result)))
;; cquery should apply the specific class in eglot
(when (equal "cquery" eglot-cpp-ls)
(push 'eglot-cquery result))
result))
(eval-after-load 'eglot
'(progn
(add-to-list 'eglot-server-programs
(cons '(c-mode c++-mode foo-mode) #'whatacold/eglot-ccls-contact))))
至此 LSP 基本可用了。