语言服务器看到的编译上下文:编辑器说没问题,构建却失败了
编辑器里没有一个红色波浪线,提交之后 CI 构建失败:调用了一个不存在的函数。另一个人的编辑器则满屏报错,说找不到头文件,可他本地的构建一切正常。两个人用的是同一个语言服务器、同一份代码。
语言服务器(LSP 服务端,如 clangd、jdtls、pyright、rust-analyzer)给编辑器提供补全、跳转、诊断和重命名。它们的分析结果之所以比文本搜索准确,是因为它们像编译器一样理解代码;也正因为如此,它们需要和编译器一样的输入:包含路径、宏定义、语言标准、依赖的版本。这些信息不在源文件里,而在构建配置里。语言服务器用的编译上下文和真实构建不一致时,它给出的就是另一份代码的分析结果。
本文用 clangd 19 和 CMake 3.31,在一个最小的 C 项目上比较几种编译上下文下的诊断。实验见文末。
一、没有编译上下文:语言服务器只能猜
项目结构很简单:头文件在 include/,CMake 把它加进包含路径;一个构建选项 USE_V2 打开时定义同名的宏。
#include <stdio.h>
#include "config.h"
int main(void) {
#ifdef USE_V2
int n = v2_total(MAX_ITEMS);
#else
int n = MAX_ITEMS;
#endif
printf("%d\n", n);
return 0;
}没有任何构建信息时,clangd 只能用一条默认的编译命令分析这个文件:
没有 compile_commands.json:2 个错误:Line 2: 'config.h' file not found|Line 8: use of undeclared identifier 'MAX_ITEMS'这就是开头那位同事满屏报错的原因:代码本身没有问题,是语言服务器不知道 include/ 在包含路径里。
二、编译数据库:把构建配置交给语言服务器
C/C++ 的标准做法是编译数据库(compile_commands.json):一个 JSON 数组,记录每个源文件实际使用的编译命令。CMake 用一个选项就能生成:
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ONCMake 生成的编译命令:/usr/bin/clang -Iinclude -o CMakeFiles/shop.dir/src/main.c.o -c src/main.c
用 build/ 的编译数据库:0 个错误编译数据库的好处是逐个文件记录:不同目标的包含路径和宏可以完全不同,手写一份全局的编译参数(compile_flags.txt)表达不了这一点。其他构建系统也有对应的生成方式(Bazel、Meson、Make 配合 Bear 之类的工具)。数据库默认生成在构建目录里,clangd 会在源文件所在目录及其父目录、以及其中名为 build 的子目录里查找;常见做法是在项目根目录放一个指向它的符号链接,或在 .clangd 配置里指定。
三、陈旧的数据库:编辑器说没问题
构建配置会变。把 USE_V2 打开,重新构建:
构建改成 -DUSE_V2=ON:cmake --build 失败:error: call to undeclared function 'v2_total'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration]
编辑器仍指向构建前生成、不含 USE_V2 的数据库:0 个错误语言服务器还在用旧的编译命令分析代码。旧命令里没有 -DUSE_V2,预处理器走的是 #else 分支,v2_total 那一行根本没有被编译,自然没有错误。这就是开头「编辑器没有波浪线、CI 失败」的情形。换成与构建一致的数据库之后:
构建后的编译命令:/usr/bin/clang -DUSE_V2 -Iinclude -o CMakeFiles/shop.dir/src/main.c.o -c src/main.c
换成与构建一致的数据库:1 个错误:Line 6: call to undeclared function 'v2_total'; ISO C99 and later do not support implicit function declarations语言服务器和构建报出了同一个错误。
数据库陈旧的常见原因:改了构建选项但编辑器指向另一个构建目录;本地有多个构建目录(Debug、Release、不同的选项组合),链接指向的不是 CI 用的那一个;新增的源文件还没有重新运行 CMake,数据库里没有它,clangd 只能从相邻文件推断一条命令。
四、其他语言是同一个问题
| 语言 | 编译上下文从哪里来 | 不一致时的表现 |
|---|---|---|
| C/C++ | compile_commands.json | 找不到头文件、宏分支不对 |
| Java | Maven/Gradle 的模型(jdtls 导入项目) | 依赖版本不同、生成的源码缺失、模块路径不对 |
| Python | 解释器与虚拟环境、pyproject.toml | 语言服务器用了系统 Python,第三方包全是「无法解析的导入」,或者解析到另一个版本的包 |
| TypeScript | tsconfig.json(含 paths、strict) | 编辑器用了另一个 tsconfig,或另一个版本的 TypeScript |
| Rust | Cargo 的 feature 与 target | 关掉的 feature 下的代码不被分析 |
规律是一样的:语言服务器只是又一个「构建」,它的输入要和真实构建来自同一个地方。几条实践:
- 从构建系统生成编译上下文,不手写;构建配置改了,重新生成。
- 项目根目录要找对。语言服务器通常从打开的文件向上查找项目标记(
.git、pyproject.toml、compile_commands.json);在仓库外层目录打开编辑器,或者 monorepo 里嵌套多个项目时,它可能把整个目录当成一个项目,扫描慢、结果也不对。 - Python 要显式指定解释器,让语言服务器、lint 工具和测试用同一个虚拟环境。
- 检查器各管一件事:语言服务器负责语义诊断,lint 和格式化工具负责风格,构建和测试负责最终结论。几个工具同时报同一类问题时,关掉重复的那个,而不是让它们互相覆盖。
五、语言服务器给出的结论能信到什么程度
即使编译上下文完全一致,语言服务器的分析也只覆盖一部分:
- 它分析的是缓冲区,包括还没保存的修改;构建读的是磁盘文件。这在编辑时是优点,但「编辑器里没有错误」不等于「磁盘上的版本能构建」。
- 它只分析当前配置下的那一条宏分支、当前 feature 组合、当前平台;其他配置要靠 CI 的构建矩阵。
- 重命名、提取函数等重构是基于它看到的代码做的:没有被当前配置编译的代码、反射或字符串里的引用、生成的代码,都可能被漏掉。重构之后仍然要完整构建并运行测试。
所以诊断和重构的最终判断标准始终是构建和测试,语言服务器是让反馈提前的工具。不依赖语言服务器、只按名字索引的 tags 跳转,见Vim 的局部选项、quickfix 与 tags。
六、检查清单
- C/C++ 项目从构建系统生成
compile_commands.json,并让语言服务器找到与 CI 相同配置的那一份。 - 构建选项、源文件列表变化后重新生成编译上下文。
- 确认语言服务器识别的项目根目录和解释器(虚拟环境、JDK、TypeScript 版本)。
- 「编辑器没有报错」之后仍然运行构建和测试,CI 覆盖其他配置组合。
- 语言服务器的重命名之后完整构建,检查字符串、反射与生成代码中的引用。
配套实验
- codesphere-labs/engineering/compile-context-for-language-servers:clangd 19.1.7、CMake 3.31.6:没有编译数据库、CMake 生成的数据库、构建打开宏后旧数据库与新数据库下的诊断(验证记录)
参考资料