Skip to content

语言服务器看到的编译上下文:编辑器说没问题,构建却失败了 ​

编辑器里没有一个红色波浪线,提交之后 CI 构建失败:调用了一个不存在的函数。另一个人的编辑器则满屏报错,说找不到头文件,可他本地的构建一切正常。两个人用的是同一个语言服务器、同一份代码。

语言服务器(LSP 服务端,如 clangd、jdtls、pyright、rust-analyzer)给编辑器提供补全、跳转、诊断和重命名。它们的分析结果之所以比文本搜索准确,是因为它们像编译器一样理解代码;也正因为如此,它们需要和编译器一样的输入:包含路径、宏定义、语言标准、依赖的版本。这些信息不在源文件里,而在构建配置里。语言服务器用的编译上下文和真实构建不一致时,它给出的就是另一份代码的分析结果。

本文用 clangd 19 和 CMake 3.31,在一个最小的 C 项目上比较几种编译上下文下的诊断。实验见文末。

clangd 用的编译命令clangd 的诊断没有数据库默认命令(不含 -Iinclude)2 个错误:找不到 config.hCMake 生成clang -Iinclude -c main.c0 个错误构建改为 USE_V2clang -DUSE_V2 -Iinclude ...构建失败:v2_total 未声明编辑器仍用旧数据库clang -Iinclude -c main.c0 个错误(与构建不一致)换成新数据库clang -DUSE_V2 -Iinclude ...1 个错误,与构建一致语言服务器是又一个「构建」:输入(包含路径、宏、依赖、解释器)要来自真实构建,构建配置改了就重新生成;最终结论仍以构建和测试为准
图 1 · 实测(clangd 19.1.7、CMake 3.31.6):没有编译数据库时 clangd 找不到头文件;用 CMake 生成的数据库后没有错误;构建打开 USE_V2 后失败,但编辑器仍指向旧数据库时 clangd 报 0 个错误,因为它分析的是 #else 分支;换成构建后的数据库,报出与构建相同的错误。

一、没有编译上下文:语言服务器只能猜 ​

项目结构很简单:头文件在 include/,CMake 把它加进包含路径;一个构建选项 USE_V2 打开时定义同名的宏。

c
#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 只能用一条默认的编译命令分析这个文件:

text
没有 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 用一个选项就能生成:

bash
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
text
CMake 生成的编译命令:/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 打开,重新构建:

text
构建改成 -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 失败」的情形。换成与构建一致的数据库之后:

text
构建后的编译命令:/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找不到头文件、宏分支不对
JavaMaven/Gradle 的模型(jdtls 导入项目)依赖版本不同、生成的源码缺失、模块路径不对
Python解释器与虚拟环境、pyproject.toml语言服务器用了系统 Python,第三方包全是「无法解析的导入」,或者解析到另一个版本的包
TypeScripttsconfig.json(含 paths、strict)编辑器用了另一个 tsconfig,或另一个版本的 TypeScript
RustCargo 的 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 覆盖其他配置组合。
  • 语言服务器的重命名之后完整构建,检查字符串、反射与生成代码中的引用。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权