命令解析
背景
语言服务器从编译数据库获知如何编译每个文件,编译数据库通常是由构建系统生成的 compile_commands.json:
g++ -std=c++20 -O2 -g -I../include -DNDEBUG -c src/foo.cpp语言服务器无法直接按原样运行这条命令:
- 这是一条驱动程序命令。
g++会自行选择前端、标准库和系统头文件;clice 运行的是 Clang 前端,需要将这些信息全部显式写出——目标平台、系统头文件目录、默认语言标准。这些内容都不在编译数据库中,而是由编译器提供。 - 命令带有不影响语言服务器语义分析的选项:
-g、-c、-o、依赖文件等。 - 命令存在大量重复。 同一个构建目标下的一万个翻译单元共用同一个编译器及其基础选项,只在包含路径和宏定义上有所不同。
其中影响最大的是缺失的隐式信息:找不到系统头文件时,#include <vector> 会报错 "file not found"——这一直是 clangd 收到反馈最多的问题之一(clangd#1262、clangd#1691)。clangd 只有在 --query-driver 点名该编译器时才会向它查询,这需要用户自行排查并手动配置(clangd#1219)。clice 会主动查询每条命令所用的编译器,识别其编译器家族,并将查询次数控制在很小的规模。
选项分类
每条命令使用 Clang 自身的选项表按选项 ID 解析一次:同一选项的各种写法——连写、拆写、cl 的 / 语法——都会归入同一类别。每个选项要么排除,要么保留在以下某一类别中:
- 排除项:仅构建流程读取的选项——输出路径、编译模式、依赖文件、耗时统计、汇编器选项、GCC 的模块映射器、cl 的模块接口输出——以及仅影响目标代码生成的选项(
-g、-ffunction-sections)。看似属于代码生成但会改变预处理器的选项则保留:-O会定义__OPTIMIZE__,-fPIC会定义__PIC__,-fsanitize=address会改变__has_feature的返回值。 - 诊断:警告选项。它们只改变显示哪些诊断,不影响其他任何结果。
- 用户内容:单个文件所需的配置——包含路径、宏定义、强制包含。它们不会改变编译器关于自身的查询结果。
- 语义:其余选项——
-std、--target、-march——编译器的查询结果取决于它们。 - 预编译:命令读取或写出的 PCH,以及命令是否写出模块——这两者都由 clice 自行构建。
- 输入,即源文件本身,占据独立槽位;以及未知选项,计入命令标识。
解析后的命令集中保存在一张表中,每条命令只存一份。下游所有环节——工具链查询、规则应用、命令行渲染——均直接读取这一结构化形式;命令的标识是它的命令哈希:由保留的全部参数、输入文件的位置以及工作目录共同计算得出。索引会记录每个翻译单元的命令哈希,用户切换编译条目时也是通过它来指明目标。
这一分类也确保了编译器查询次数很少:编译器的回答不取决于用户内容,因此仅在用户内容上有差异的文件可共享同一份查询结果。数万个文件最终只会归并为几十次查询。
规则、成员与宿主
clice.toml 中的规则在当前生效的构建配置下决定编译数据库条目的最终形态;所有使用方——编辑器、依赖扫描、后台索引、命令行工具——都向项目查询,从不直接读取编译数据库。
- 条目。 文件的编译条目按构建顺序排列:先是匹配该文件的规则所指定的编译数据库,其次是其他规则指定的数据库,最后是自动发现的数据库。第一条为默认条目,用户可手动切换为其他条目。规则只决定优先级,任何条目都不会因为文件未命中某个匹配模式而消失。
- 编辑。 按声明顺序应用每条匹配规则的
remove和append。一个文件从另一个文件取得的命令(如头文件取自宿主的命令)会同时应用两个文件的规则编辑,使头文件看到的宏与宿主编译时一致。 - 成员。 构建中的翻译单元:拥有数据库条目的文件、被某条规则的
default_command覆盖的源文件,以及临时成员(见下文)。编译数据库中列出的头文件同样属于成员,会作为独立的翻译单元建立索引。 - 宿主。 没有独立编译命令的头文件会在某个包含它的翻译单元中参与编译,且该单元的语言必须与头文件兼容(
.hpp绝不会按 C 编译)。候选单元依次按头文件自身规则指定的数据库、同名文件主干、同目录以及路径距离排序;其中第一个在编译时实际进入该头文件的单元即成为其宿主。参见编译上下文。 - 借用的命令。 既无条目、无宿主,也无
default_command的文件会向附近同语言的翻译单元借用命令:优先取同目录下的单元(其中共享文件主干的排最前),否则对头文件而言取包含目录涵盖它的单元,最后取路径最近的单元。借用的命令会标明来源,缺失文件的诊断也会说明该命令是借来的。 - 临时成员。 用户打开或保存的项目源文件若既未列在任何编译数据库中,也未被任何翻译单元包含——例如生成编译数据库之后新建的文件,或无编译数据库项目中的源文件——就会以借用的命令(无可借用时则用 clice 内置命令)加入构建:它会建立索引、向外提供自身声明的模块,并充当所包含头文件的宿主。被其他翻译单元包含的源文件(如 合并构建(unity build)的组成部分)则改为在包含它的单元上下文中编译。临时成员记录在重启后依然保留;一旦有编译数据库列出了该文件,即由数据库接管。
发现
未声明任何编译数据库也未声明 default_command 的配置会自动发现数据库:查找项目根目录及其直接子目录中的 compile_commands.json,并在打开文件时查找从该文件所在目录到根目录之间各级目录中的数据库。自身含有 clice.toml 或 .clice/config.toml 的目录视为另一个独立项目。指向已发现数据库的符号链接视为同一份数据库。服务器会在轮询时持续查找,因此稍后生成的数据库一旦出现就会加载。只要有任何规则显式声明了命令来源,自动发现就会关闭:显式声明即代表完整意图。
查询编译器
每个编译器家族都按各自的方式查询,每个不同的查询键只问一次,并将结果缓存起来:
- GCC 风格驱动程序(GCC、clang、Apple clang、emcc、zig、交叉编译与裸机 GCC)使用命令自身的标志,以详细输出并打印宏定义的方式(
-E -dM -v)预处理一个对应语言的空文件。一次运行即可获知全部信息:clang 驱动程序会打印 clice 所需运行的前端任务;GCC 会打印目标平台、头文件搜索列表以及 specs 注入的选项,再由 clang 驱动程序锁定该目标平台并结合 GCC 的搜索列表拼出前端命令。输出的宏会给出默认语言标准。驱动程序底层是 clang 还是 GCC 由其输出内容判定,从不看文件名:macOS 上的/usr/bin/c++实际上是 clang。 - cl 与 clang-cl 预处理一个写有
_MSVC_LANG和_MSC_FULL_VER的文件,以获取语言标准以及 cl 的编译器版本,再由处于 cl 模式的 clang 驱动程序构建前端命令。 - nvcc 通过试运行给出 CUDA 根目录、宿主编译器、nvcc 宏定义及其头文件目录;GCC 或 clang 宿主编译器按上述方式继续查询,最后由 clang 驱动程序构建 CUDA 编译命令。
- clice 不主动运行的编译器——规则设置了
query_toolchain = false,或查询失败的编译器——会将命令直接交给 clang 自身的驱动程序,按以该名称安装的 clang 来解析;查询失败时会在受影响文件的顶部显示错误信息。
无法直接沿用的查询结果会针对 clang 做适配。GCC 的私有头文件目录中既混有 clang 无法解析的 GCC 版内置头文件,也有仅 GCC 提供的头文件(如 omp.h):clice 会保留该目录,并隐藏其中可由 clang 自带头文件替代的文件;丢弃 clang 不认识的 CPU 与 ABI 名称;把 GCC、cl 和 nvcc 升级为错误的警告降回普通警告,因为 clang 会报告那些编译器从不报告的警告;将 GCC 的 -fmodules 解释为 C++20 模块,而非 clang 的头文件模块。编译器前面的启动器(ccache、sccache、distcc、icecc)会先剥离。
启动时,构建涉及的所有不同工具链查询会在依赖扫描阶段并行发起,使首批编译直接命中缓存。
搜索路径
前端命令的包含目录按 Clang 的顺序归入四层——双引号目录(-iquote)、尖括号目录(-I)、系统目录与后置目录(-idirafter)——并按 Clang 的规则去重:同时出现在尖括号组和系统组中的目录,会在系统组所在的位置参与搜索。clice 自身的头文件查找、依赖扫描与 #include 代码补全都沿用这一顺序,确保 #include_next 与同名头文件解析到的文件与编译器完全一致。
常见问题
- 为什么 GCC 命令会按 clang 解析? clice 的前端是 clang。标识编译器的宏保留为 clang 的定义——即定义了
__clang__——因为若改用 GCC 的标识宏,会让代码走入专为 GCC 编写的分支,其中充斥着 clang 无法解析的内置函数。其余描述构建环境的信息则全部跟随真实编译器:目标平台、头文件搜索列表、语言标准以及编译器配置注入的宏。 - 为什么按命令中写出的名称查询,而不先解析真实路径? 驱动程序的行为取决于被调用的名称:
clang++是指向clang的链接,靠名称开启 C++ 模式;arm-none-eabi-g++则在名称中编码了目标平台。提前解析链接会同时丢失这两项信息。 - 为什么搜索顺序必须与 Clang 完全一致? 两个同名头文件中究竟包含哪一个,以及
#include_next从何处继续向后查找,都由搜索顺序决定。任何偏差都会让用户看到与实际编译不一致的代码。
已知限制
- 自动发现不会递归扫描整个目录树。 嵌套子项目的编译数据库会在打开其中某个文件时加载;在规则中显式指定该数据库即可从一开始就覆盖它。
- 只有编辑器会将未列出的文件加入构建。 通过其他方式出现在磁盘上、既未列在任何编译数据库中也未被规则的
default_command覆盖的源文件,在被打开或保存之前(或重新生成的数据库列出它之前)一直处于构建之外。 - GCC 专属代码按 clang 的视角解析:仅 GCC 支持的内置函数、clang 不支持平台上的
__float128以及嵌套函数,都会显示 clang 的报错。 - CUDA 按 clang 的 CUDA 模式解析:仅 nvcc 接受的语法会显示 clang 的报错。
- 既不按 clang 也不按 GCC 格式输出的驱动程序——例如 Intel 传统编译器
icc或其他厂商的专有编译器——会回退为 clang 针对本机的默认配置,并在诊断错误中说明这一点。 - 仅识别常见的编译器启动器。 其他启动器会被误认为编译器本身,请先将其从编译数据库中剥离。
- clice 自身的头文件查找不读取 Header Map(影响依赖扫描、补全和文档链接),但实际编译仍会正常应用它们。
- 不跟随头文件中的 Cygwin 符号链接:clice 内置的 clang 会把链接文件本身当作普通文件读取。
- 导出给外部工具的命令会省去含被隐藏文件的目录。
clice command convert输出的命令不含 GCC 私有头文件目录及其他版本 clang 的内置头文件目录;clice command <file> --json会列出各目录中被隐藏的文件。
