问题
用Clangd 作为 VSCode 的 C/CPP LSP 来给 Lean 写 FFI 的时候会出现很多报错,是很经典的找不到头文件。因为没有配置
includepath
. Lake 毕竟不是专门为 C 项目而设计的构建系统,自然没有命令来导出一个 compile_commands.json
(Compilation database, compdb) 供 Clangd 分析使用。(当然实际上 Lake 自己会生成一个类似的玩意 *.trace,除了记录命令还存储 hash 以便做 incremental compilation,但 Clangd 不能用,没什么关系了。见 17.1.1.1. Builds - Replaying traces)
为方便叙述,先给一个 lakefile 中定义的 FFI 样例:
target ffi.o pkg : FilePath := do let oFile := pkg.buildDir / "c" / "ffi.o" let srcJob <- inputTextFile <| pkg.dir / "c" / "ffi.c" let weakArgs := #["-I", (<- getLeanIncludeDir).toString] buildO oFile srcJob weakArgs #["-fPIC"] "cc" getLeanTrace
实际上去翻 Lake 的源代码,会发现
build0
就是 spawn
套了几层,这里的 weakArgs
(system-dependent) 和 #["-fPIC"]
都是直接传给 subprocess 的;后边的 c++
则是编译器。当然我们这里都用 Clangd 了,肯定是指向 clang
了.分析
- 一种办法是开
-v
,--verbose
来手动把 Lake 打印出的命令手动转换成一个~.json
,可是过于麻烦,和手写也没什么区别。
那么,各大 C/CPP 构建系统都带一个导出 compdb 的东西,唯独 clang 没有么?既然上面的代码已经暴露出编译的参数,我们可以看看能不能添加合适的参数来让编译器干这件事。
查询一下 clang 的文档就可以发现,还真有:
-MJ<arg>
Write a compilation database entry per input
不过这里我认为如果标示成
<file>
的话会更清晰,其他条目也不是没有这样做的。那不管怎么说,先改一下编译参数
#["-fPIC", s!"-MJ{art}”]
:$ lake build ffi.o trace: .> c++ -c -o ././.lake/build/c/ffi.o.json ././c/ffi.cpp -I /Users/notch1p/.elan/toolchains/leanprover--lean4---v4.17.0/include -fPIC -MJ././.lake/build/c/ffi.o.json
再看看生成的 JSON:
$ cat ffi.o.json | jq { "directory": ..., "file": "././c/ffi.cpp", "arguments": [...] } jq: parse error: Expected value before ',' at line 1, column 1396
parse error 了,但很正常。因为 Clang 毕竟不是一个构建系统,每次只能构建项目的一个(或几个)object,反正不是整个项目,因此也没有办法导出整个 DB,也就是说我们需要的是
Array<Map>
而不是单个 Map
. 关于 trailing comma 报错反而是好事:因为 clang 考虑到最后用户自行组装 compdb 文件的时候,只需要遍历一遍所有 JSON,读出字符串并相连,去掉最尾端逗号即可。输出条目
这些先不管。我们不需要每次编译一个 target 的时候都要重新写一遍 compdb,不妨先另写一个 target
gen_compdb
来专门干这件事:target gen_compdb pkg : FilePath := do let prefixp := pkg.buildDir / "c" let ( jsonp, artp ) := ( pkg.dir / "compile_commands.json" , prefixp / "ffi.o.json") let srcJob <- inputTextFile <| pkg.dir / "c" / "ffi.c" let weakArgs := #["-I", (<- getLeanIncludeDir).toString] let some cc <- IO.getEnv "CC" | logError "No C compiler is set via $CC" *> pure Job.nil unless <- FilePath.pathExists prefixp do createDirAll prefixp buildO artp srcJob weakArgs #["-fPIC", "-fsyntax-only", s!"-MJ{artp}"] cc getLeanTrace
有几个很简单的细节:
- 因为
lake clean
相当暴力,单纯是删除.lake/build
目录,所以最好把生成的中间产物*.o.json
全部放在和*.o
一个路径下面,这样能够一起删除;
- 也正是因为它很暴力,所以在
lake clean
之后跑这个 target 会报错——目录没了,因而还要加一个createDirAll
-fsyntax-only
的好处是类似 lint,不输出产物。
- 不是
default_target
,lake build
不会编译。
此外,其实还可以用
script
这个关键字,通过 lake run
来执行。但是我们仍然将其声明成一个 target,这是因为 build0
不止专门用来编译 object: build0
本质上就是通过检测文件是否存在或 subprocess 返回值来确定编译的成功与否。因此只需要给定编译产物名即可,这样相较于 script
又多了一层检测编译产物的保护。构建 DB
那么现在的问题主要是如何构建串连起这些条目——这也算不上问题,我上面也说过解决办法。那么就是怎么样解决最 portable 吧。
- 通过
proc
生成 subprocess,用其他工具解决这一问题(sed, jq)。显然这并不 portable,而且写着也麻烦,不如干脆套一层 Makefile (sh) 来解决算了。
sed
oneliner:# GNU sed sed -e '1s/^/[\n/' -e '$s/,$/\n]/' *.o.json > compile_commands.json # BSD sed sed -e '1s/^/[\'$'\n''/' -e '$s/,$/\'$'\n'']/' *.o.json > compile_commands.json
显然这并不 portable,而且相当于 undermines 我们之前所作的努力了:既然决定用 shell,那干脆直接在 Lakefile 中写一个
proc
调 shell 脚本接管一切了。那最终办法只有一个:用 Lean 自己解决。毕竟也不涉及到具体的 JSON Parsing。
这里需要说明的是,Lake 的绝大部分 Monadic 的函数,无非是
FetchM
:#reduce (types := true) FetchM fun α => IndexBuildFn RecBuildM -> CallStack BuildKey -> ST.Ref IO.RealWorld BuildStore -> BuildContext -> Log -> IO.RealWorld -> EStateM.Result Empty PUnit (EResult Log.Pos Log α)
在这个 path 上的都好从前向后 lift. 那就没有什么拘束(接上):
open IO.FS in -- target [...] (body of target gen_compdb) <* concatJsonWriteFile jsonp pkg where concatJsonWriteFile (p : FilePath) (pkg : Package) : IO Unit := do let dir := pkg.buildDir / "c" let jsons := (<-dir.walkDir).filter λ path => path.toString.endsWith r".o.json" let cat_json <- jsons.foldlM (init := "") λ acc path => readFile path >>= pure ∘ (· ++ acc) pure s!"[{cat_json.dropFirstRight (· == ',')}]" >>= writeFile p
也不需要正则,相当之迅速。因为只需要去掉最右边第一个逗号,用空白字符替代一下即可:
def String.dropFirstRight (s : String) (p : Char -> Bool) (recursor := s.endPos) := match recursor with | ⟨0⟩ => s | pos @ ⟨next + 1⟩ => if p $ s.get pos then s.set pos ' ' else dropFirstRight s p ⟨next⟩ termination_by recursor.1
termination_by
亦可,Lean 会自己 congr 去掉 ⟨⟩
constructor这里 Lean 用自欺欺人的方式
extern
了 get
和 set
这俩函数,也就是说标准库里的定义是写给 typechecker 看的,而编译时是用高性能实现(即正常实现)覆盖的,并非是对着那个 List Char
慢慢递归,runtime 中性能损失少。这个写法并不很安全,
Pos
索引的是每个 byte 的位置(同理 get
set
也是),既然我们是从后往前数的,要求第一个逗号之前不能有非 ASCII 字符,否则当某个多 Byte 的字符包含 ,
的 byte 时就会出错。(但在这里逗号前面只能是换行,所以不成问题,基于性能考虑这样写)这样,我们就有了十分 portable 的方式来生成一个 compdb 方便 Clangd 读取——是这样吗?
坑
同步问题
其实这时候是能跑的,但是一般可能要跑第二遍才能跑通。因为 target 的 build 操作是异步的 ,还套了几层。其返回的一个 build spec 之后会被 API 隐式调用再来跑,在这里返回的就是
build0
的调用结果。但是我们要求这两步必须要 synchronous:先 build,再 concat json. 且最好是在一个 target 内完成这两项工作(耦合一点没问题)
因而:
- 写下
build0
的时候只是创建一个SpawnM (Job FilePath)
,
- 直接 lift 到
FetchM
- 关键 通过
runBuild
手动来确保 synchronous 的 build.
两种可行的解决办法
这里有两种办法,第一种耦合度低,但是简单。另写一脚本调用
gen_compdb
target,build 后返回。这里相当于利用了 target 的封装性,直接 fetch
即可script "compdb" := do pure 0 <* runBuild (gen_compdb.fetch) <* concatJsonWriteFile "compile_commands.json" _package
但我们希望耦合一点。这么一个小操作不太需要维护,尽量写在一个 target 内,那么必须要拆开封装,手动执行每一步操作(就像上面分析的那样):
-- target [...] ( runBuild $ buildO artp srcJob weakArgs #["-fPIC", "-fsyntax-only", s!"-MJ{artp}"] "cc" getLeanTrace ) *>pure Job.nil <*concatJsonWriteFile jsonp pkg
前面说过,target 的返回值是实际要执行的 build. 因为我们已经在 target 内部手动执行了这个 build,就不应该再返回一次(否则 build 两次也没什么意思)。这里可以用
Job.nil
生成一个 dummy job. lift 到 FetchM
中再返回这个 dummy job 即可。- 一种顾虑是会有各种 call 带来的性能损失,不过大部分函数都被标注了
@[inline] @[always_inline]
所以也不成问题;
- 还有一种顾虑是因为我们最后返回的是一个 dummy job 而不是真实 Job,所以编译器报错是不会经过 IO 输出到 stdin 上的,会被直接丢弃。
后者的话没有太多办法,因为只是处理 compdb,至于编译器之类的报错不输出应该也没什么影响。直接调
lake build
构建 FFI target 就能看到输出了。这样,我们再稍微润色一下前面部分,一个简单的整体的实现就是:
open IO.FS String in target gen_compdb pkg : Unit := do let prefixp := pkg.buildDir / "c" let ( jsonp, artp ) := ( pkg.dir / "compile_commands.json" , prefixp / "ffi.o.json") let srcJob <- inputTextFile <| pkg.dir / "c" / "ffi.c" let weakArgs := #["-I", (<- getLeanIncludeDir).toString, "-I", "/usr/local/include"] let some cc <- IO.getEnv "CC" | logError "No C compiler is set via $CC" *> pure Job.nil unless <- FilePath.pathExists prefixp do createDirAll prefixp ( runBuild $ buildO artp srcJob weakArgs #["-fPIC", "-fsyntax-only", s!"-MJ{artp}"] cc getLeanTrace ) *> pure Job.nil <* concatJsonWriteFile jsonp pkg.config where concatJsonWriteFile (p : FilePath) (pkg : PackageConfig) : LogIO Unit := do let dir := pkg.buildDir / "c" let jsons := (<-dir.walkDir).filter λ path => path.toString.endsWith r".o.json" let cat_json <- jsons.foldlM (init := "") λ acc path => readFile path >>= pure ∘ (· ++ acc) logInfo $ reprStr jsons liftM <| pure s!"[{dropFirstRight cat_json (· == ',')}]" >>= writeFile p dropFirstRight (s : String) (p : Char -> Bool) : (r : _ := endPos s) -> String | ⟨0⟩ => s | pos@⟨next + 1⟩ => if p $ s.get pos then s.set pos ' ' else dropFirstRight s p ⟨next⟩ termination_by r => r.1
后记
那么,有人会问:干嘛不用微软的 C/CPP 插件包?能在 VSCode 里边直接写workspace-specific
includepath
不好么?写那么多屁用没有原因有下:
- 这玩意真的是 LSP 协议么?我原来是用它的,debug 其实挺不错的,但是真的不快,而且没有 intellisense-as-I-type,每次需要 cmd + s 保存一下才行。这实在是太反直觉了。
- 不 Portable. 颠覆本文主旨。那万一我不用 VSCode,我喜欢用 Vim 和 Emacs 呢?实际上我三个换着用呢?至少这三家 Clangd 全行。
那我问你