命令解析
背景
C++ 语言服务器需要知道如何编译项目中的每个文件。这些信息来自编译数据库(CDB),通常是由构建系统生成的 compile_commands.json 文件。CDB 中的每条记录都包含源文件路径和编译命令,例如:
g++ -std=c++20 -O2 -fPIC -I../include -DNDEBUG -c src/foo.cpp这条命令记录了构建系统在实际构建过程中调用编译器的方式。然而,语言服务器无法直接使用它,原因有三个。
第一,这是驱动级命令,而非前端命令。 上面的 g++ 是编译器驱动程序,负责选择正确的前端、链接器和标准库。语言服务器实际使用的是 Clang 前端(cc1),而从 g++ 转换到 cc1 涉及大量隐式处理,包括确定目标三元组、注入系统头文件搜索路径、设置默认语言标准等。这些信息都没有显式出现在 CDB 中,而是由编译器隐式提供。
第二,命令中包含与语义无关的选项。 -O2 和 -fPIC 只影响代码生成,不影响语义分析——语言服务器不进行代码生成,因此用不到这些选项。同样,-c(编译模式)和 -o(输出文件)是构建产物相关的指令,对语言服务器没有意义。未经筛选就将它们传给前端会增加不必要的复杂度,甚至可能引发错误。
第三,大型项目中的命令高度冗余。 在拥有数万个源文件的项目中,绝大多数文件使用相同的编译器和语义选项,只有头文件包含路径和宏定义有所不同。如果不进行去重,每个文件都需要单独查询工具链,造成内存浪费并延长启动时间。
在这三个问题中,第一个问题对用户体验的影响最大:缺少隐式信息。当语言服务器无法正确获取系统头文件路径时,用户会遇到标准库头文件错误——#include <vector> 报告 "file not found",或者 GCC 内置类型特征被标记为未声明的标识符。在 clangd 的问题跟踪器中,这类问题一直是最常被报告的问题之一(clangd#1262、clangd#1691)。
clangd 的解决方案是使用 --query-driver 参数:用户手动指定需要探测的编译器,随后 clangd 查询这些编译器以获取系统路径。这种方式有两个问题。首先,它需要手动配置——用户必须知道项目使用的是哪个编译器,并正确配置 glob 模式。其次,该参数无法独立发挥作用——它依赖 CDB 中已有的命令来触发探测(clangd#1219)。在交叉编译和嵌入式开发场景中,用户往往需要反复调试配置,clangd 才能正确识别工具链。
clice 实现了整个命令处理流程的自动化:从 CDB 读取命令后,它会自动识别编译器家族(GCC、Clang、MSVC 等)、自动探测工具链,并通过多级去重最大限度地降低启动开销。用户无需手动配置任何工具链相关参数。
设计
命令处理的核心任务是将 CDB 中的原始驱动命令转换为可供 Clang 前端使用的 cc1 参数。这个转换过程涉及四个概念层:参数分类、命令分离、工具链探测和搜索路径提取。
参数分类
加载 CDB 时,会将每个编译选项归入以下七类之一:
丢弃:语言服务器不需要的构建产物相关选项,包括输出文件(
-o)、编译模式(-c)、依赖扫描(-M系列)、PCH 构建(-emit-pch),以及驱动程序本身针对该输入会忽略的选项。代码生成:只影响代码生成后端、不影响语义分析的选项。包括位置无关代码(
-fPIC)、栈保护(-fstack-protector)、帧指针(-fomit-frame-pointer)、调试信息(-g系列)、LTO 等。它们不会改变 AST 或诊断输出。注意:
-O和-fsanitize=虽然看似与代码生成有关,但不属于此类。-O会定义__OPTIMIZE__宏,-fsanitize=address会影响__has_feature(address_sanitizer)。它们会改变预处理器状态,因此属于语义选项。诊断:警告控制选项(
-Wall、-Wno-*、-Werror)。它们会改变发出哪些诊断,但不会影响 AST 或工具链探测结果。用户内容:可能因文件而异、但不影响工具链探测结果的选项。包括包含路径(
-I、-isystem、-iquote、-idirafter)、宏定义(-D、-U)和强制包含(-include)。它们表达的是“这个特定文件还需要什么”。语义:其余可识别的选项——它们会影响编译语义,并在工具链探测中发挥作用。例如
-std=c++20、-target、-march=等。输入:源文件本身,它在解析后的命令中占据一个明确的槽位,而非众多普通字符串中的一个。
未知:解析器无法识别的选项(例如,比 clice 内嵌 LLVM 更新的编译器所提供的选项)。它们会参与构成命令标识,但来自 CDB 的未知 Token 不会渲染到 clice 执行的编译命令中;用户在配置规则中写入的未知 Token 则会保留。
分类基于 Clang 自身的选项表(OptTable),使用选项 ID 而非字符串匹配。
结构化命令
分类完成后,每条命令只解析一次,形成结构化配置(CompileConfig):一个由已分类参数组成的驻留序列,其中输入文件占据明确的槽位。相同的配置会去重为单个实例,由稳定的 ConfigID 标识;字符串也会被驻留,以便通过稳定的指针进行比较。所有下游处理——工具链探测、渲染、规则和标识——都使用这种结构化形式;不会重新解析命令字符串。
有两个重要的派生视图:
- 渲染:同一份结构化配置可以按需渲染成编译器驱动程序命令行(用于探测,或向代理提供可运行的命令),也可以与探测结果组合成前端参数。渲染会规范化别名的拼写形式,因此 CDB 中仅形式上的差异不会影响标识。
- 条目标识:配置中与前端相关的视图,连同输入文件的槽位位置和工作目录,会共同哈希为条目的标识哈希——这是用于 CDB 差异比较、索引快照验证和固定上下文选择的稳定标识。
这种分类还能提高工具链探测的缓存效率。探测需要实际调用编译器驱动程序(例如运行 g++ -dumpmachine 或 clang++ -###),通常耗时 100ms 或更长。探测结果只取决于驱动程序和与探测相关的选项——用户内容选项(-I、-D)不会影响驱动程序输出的系统路径或目标三元组。因此,即使各文件的 -I 路径不同,只要探测相关视图相同,就会共用同一份探测结果。在实际项目中,上万个文件通常会归并为几十个不同的探测键。
编译数据库
CompilationDatabase 保存每个已加载来源的条目——来源即由配置注册或在工作区中发现的一份 compile_commands.json。每个来源独立加载、重新加载和卸载;它的条目被解析、去重为 ConfigID,并保持文件内的原有顺序,因为生成器输出同一文件的多个条目时会遵循固定的配置顺序。数据库只存事实:它不决定一个文件的哪个条目胜出。
手工编写的命令——规则的 default_command、内置的兜底命令——与条目经过同一套规范化流程(intern_command),并在末尾合成输入槽位,因此声明的命令在探测、编辑和渲染方面与数据库条目完全一致。
构建
Build 是配置中 [[rules]] 的唯一读者,也是唯一知道一个文件用哪条命令编译的地方——它是配置与数据库的纯函数,由服务器和每个 CLI 入口共用(clice index、clice lint、clice inspect 加载工作区的方式完全相同)。所有使用方——上下文解析器、依赖扫描、索引器、上下文协议——都向它询问,而不是向数据库询问:
- 条目。 文件在数据库中的条目按构建顺序排列:先是匹配该文件的规则所带的来源,再是其他生效规则的来源,二者均按声明顺序排列,发现得到的来源排在最后;同一来源内则按文件中的顺序排列。第一个条目是默认选择;用户固定的选择(
clice/switchContext)可以选用其他条目。条目不会因为某个模式未涵盖其文件而消失——规则只决定优先级。 - 编辑。 每条匹配且生效的规则的
remove与append列表,按声明顺序应用——同一条规则先删后加,靠后的remove能作用于靠前规则添加的内容。头文件借用宿主的命令时会同时带上两个文件的编辑,每条规则只算一次,因此它看到的宏与宿主编译时一致。 - 命令。 文件用它的条目所带的命令编译;没有条目时,则使用第一条匹配且声明了
default_command的规则所给出的命令。两者都没有的文件使用内置命令,其驱动程序取决于 clang 根据扩展名判定的语言(clang++ -std=c++20用于所有 C++ 扩展名形式以及含义不明的.h,-x cuda --cuda-device-only用于 CUDA,其余使用clang)。 - 成员。 构建中的翻译单元:有条目的文件,以及磁盘上被某条
default_command规则的模式涵盖的源文件;后者从模式指定的目录中枚举得到,头文件从不计入。依赖扫描会运行每个成员的每一条命令,因此即使某个头文件只能通过某个文件的一个条目到达,仍然能找到该宿主。后台索引会接纳成员,除非有匹配的规则写明index = false;这项检查位于索引队列处,因此每条将工作入队的路径——启动、数据库重新加载、保存——都会遵守它。
宿主
没有自身命令的头文件,会作为包含它的某个翻译单元的一部分参与编译。宿主层会在构建所编译、且语言与该头文件相容的包含者中排序——.h 可以属于任何语言,.hpp 属于 C++ 及建立在其上的语言(Objective-C++、CUDA),.cuh 只属于 CUDA,因此 C++ 头文件永远不会按 C 编译:条目来自该头文件自身规则所指定数据库的翻译单元排在最前,其次是文件名(不含扩展名)与头文件相同的翻译单元,再次是同目录下的翻译单元,最后按路径远近排列。其中第一个能通过 include 链到达该头文件的,就是它的默认宿主——编辑器、后台索引、保存时的重新扫描和 clice inspect 得到的都是同一个答案。构建不会编译的文件(即使用内置命令的文件)永远不会成为宿主。
发现
当没有任何规则声明来源时,构建的数据库就是在工作区中找到的那些 compile_commands.json:启动时取根目录及其直接子目录中的,之后每当打开一个没有条目的文件,再取从该文件所在目录向上直到根目录的各级目录中的——位于目录树更深处的项目,会在它的某个文件第一次被打开时加载,除此之外不会主动扫描。发现得到的数据库排在声明的数据库之后,层级浅的排在层级深的之前,再按路径排序;被其中多份列出的文件默认按第一份编译,其余作为候选提供;只被靠后的某份数据库列出的文件,则由它提供命令。发现不止一份时,启动阶段会提示一次,并说明改用带标签的规则可以把它们变成可切换的配置。文件追踪器会在每次轮询时继续发现,因此启动之后才生成的数据库——无论是在根目录还是在新建的子目录中——一出现就会被加载。已发现的数据库消失后,它的条目仍然继续生效,但会让位于现存的数据库:build/ 目录被重新生成为 out/ 时,两者共有的文件会立刻改由 out/ 提供,而等它回来时再交还给它。
推断
既没有条目也没有宿主、也没有匹配的规则声明 default_command 的文件,会借用附近一个同语言的翻译单元的命令——.h 与任何语言相容,.c 从不借用 C++ 命令,.cpp 也从不借用 C 命令。出借方优先取该文件所在目录中的翻译单元,其中文件名(不含扩展名)相同的优先,其次按名称取第一个;其次,对头文件而言,取头文件搜索目录(-I、-isystem、-iquote)包含它的翻译单元,目录越近越优先——该翻译单元自身的代码正是通过这条路径找到这个头文件的,因此它的命令也正是这个头文件所面向的命令;最后取路径最接近的翻译单元。借来的命令会同时带上两个文件的规则编辑,并标记为 Inferred:决策日志会写明出借方,关于文件缺失的诊断会附带一条引导说明,而该文件仍处于构建之外——它不会被后台索引,clice inspect 也用同样的方式解析它。
合成
文件的最终命令由上述各层按固定顺序合成:优先采用用户在编辑器中为该文件所做的选择(固定的条目,或固定的宿主与 include 出现位置);没有选择时,依次回退到该文件的第一个条目、默认宿主的命令(仅限头文件)、第一条匹配规则的 default_command、附近某个翻译单元的命令(推断),最后是内置命令。随后叠加该文件(以及它借用命令的宿主或出借方)的规则编辑,最后加上本次运行的额外参数(Lint 方案带来的 clang-tidy 参数)。选择只作用于编辑器发起的编译;后台编译不带选择进行合成,也不缓存任何结果,因此索引和 CLI 看到的都是构建自身的答案。
模式针对绝对路径匹配:相对模式锚定在配置文件所在的目录(含 .. 路径段),通过 initializationOptions 传入的规则则锚定在工作区根目录;绝对模式或以 ** 开头的模式按原样匹配。带 configuration 标签的规则只在该标签生效时适用;不同的标签构成配置菜单,启动时按 --configuration、持久化的选择、default_configuration 的顺序决定哪个配置生效(参见编译上下文)。只要有任何规则声明了来源(一份数据库或一条 default_command),发现流程就会关闭:声明即代表全部意图,不会再采用工作区根目录中的数据库。只有完全没有声明任何来源的配置才会回退到发现。
配置粒度在整个流水线中保持不变:依赖扫描的搜索路径提取按实际生效的命令进行(不同的 -I 集合产生不同的搜索配置),而工具链探测会进一步去重,因为与用户内容有关的选项不会影响探测结果。
工具链
Toolchain 将驱动级命令转换为 cc1 参数。它的设计围绕两个核心能力:
编译器家族识别。 Toolchain 根据可执行文件名识别编译器家族——CompilerFamily 枚举包括 GCC、Clang、MSVC、ClangCL、NVCC、Intel 和 Zig。识别逻辑可处理多种命名变体:版本后缀(clang++-17)、架构前缀(arm-none-eabi-g++)以及 Windows 的 .exe 后缀。编译器家族决定采用哪种探测策略。
缓存策略。 缓存分为两层。探测层缓存原始驱动程序调用结果,以命令中与探测相关的结构为键(驱动程序、与探测相关的标志、输入类型——.c 和 .cpp 可能触发不同的驱动规则;如果命令对工作目录敏感,工作目录也会纳入键中)。合成层将探测结果转换为命令渲染所需的、按输入类型区分的标志集。失败的探测也会被缓存(负缓存),但暂时性失败会在冷却期后重试,而不会被永久记住。
搜索路径
SearchConfig 从 cc1 参数中提取头文件搜索路径,并将其组织为四层结构:
- 引号(
-iquote):#include "foo.h"的搜索路径 - 尖括号(
-I):#include <foo.h>的搜索路径 - 系统(
-isystem、-internal-isystem等):系统头文件路径 - 后置(
-idirafter):在系统目录之后搜索的路径
该四层模型与 Clang 的内部搜索布局相对应。各层中的路径会被去重(从尖括号层开始),去重算法复现了 Clang 的行为:如果同一路径同时出现在尖括号层和系统层,则保留尖括号层中的路径。这可以确保 #include_next 的正确性。
SearchConfig 是包含路径补全、包含路径解析、依赖图构建及其他功能的基础输入。
实现
加载与解析
CDB 加载使用 simdjson 进行流式 JSON 解析,逐条处理记录:
- 读取每条记录的
directory、file和arguments(或command)字段 - 根据驱动模式展开响应文件(
@file),并可处理 UTF-16 编码文件和嵌套响应文件 - 将 NVCC 命令转换为等效的 clang CUDA 调用
- 过滤非 C/C++ 文件(例如
.rc、.asm、.def) - 将相对文件路径解析为绝对路径
- 将每个选项解析、分类一次并存入结构化配置,同时以
directory为基准将相对包含路径转换为绝对路径 - 对相同配置进行去重,使其共享
ConfigID
解析过程还处理一个特殊情况:CMake 生成的 CDB 有时包含 -Xclang -include-pch -Xclang <pchfile> 序列(CMake 的 PCH 变通方案),加载时会检测并丢弃这些序列。
工具链探测
不同编译器家族使用不同的探测策略:
GCC:分两步。首先,调用 GCC 驱动获取两个关键信息——目标三元组(-dumpmachine)和安装路径(-print-search-dirs)。然后,将这些信息注入 Clang 驱动(--target= 和 --gcc-install-dir=),让 Clang 驱动模拟 GCC 的行为并生成 cc1 参数。这样 Clang 前端就能正确找到 GCC 的标准库和系统头文件。
Clang / Zig:通过 -### 选项调用驱动,它会打印完整的 cc1 命令行而不实际执行编译。解析输出中的第一行 cc1 命令。对于 Zig,驱动路径由两部分组成(zig cc 或 zig c++),探测时需要特殊处理。
外部驱动的版本可能比 clice 内嵌的 LLVM 版本更新,其 cc1 输出中可能包含 clice 无法识别的选项。解析时会静默丢弃未知选项,以保证兼容性。
MSVC / ClangCL:通过 --driver-mode=cl 选项将 Clang 驱动切换到 MSVC 兼容模式,然后用 Clang 驱动获取 cc1 参数。
探测完成后,会从结果中移除临时探测文件的路径和与模块输出相关的标志(它们引用了已删除的临时文件)。如果 clice 的资源目录与探测返回的不一致,会替换所有相关路径,确保前端使用匹配版本的内置头文件。
启动预热。 在服务器启动的依赖扫描阶段,会收集所有唯一的工具链缓存键并并行探测。探测子进程的管道排空和进程等待也会并发执行,以避免管道填满导致死锁。预热完成后,后续所有工具链查询都会命中缓存。
搜索路径提取
工具链探测得到 cc1 参数后,搜索路径提取会遍历这些参数,按类型将 include 路径选项分入四个层级。所有路径都会解析为绝对路径并规范化(消除 . 和 ..)。
-iprefix / -iwithprefix / -iwithprefixbefore 三个选项必须按出现顺序配合处理:-iprefix 设置前缀,后续的 -iwithprefix 将前缀添加到其路径之前,并将结果放入 After 层级;-iwithprefixbefore 则将结果放入 Angled 层级。
四个层级拼接完成后,从 Angled 层级开始去重。Quoted 层级不参与去重——同一路径同时出现在 Quoted 和 Angled 中是合法的,两者都会保留。这复现了 Clang 的内部行为。
常见问题
为什么按选项 ID 分类,而不是字符串匹配?
clice 的参数解析基于 Clang 自身的选项表(从
Options.inc生成),使用选项 ID 进行分类。字符串匹配容易遗漏边界情况:Clang 的选项语法有多种形式——-std=c++20是 joined 形式,-I /path是 separate 形式,-Wall是 flag 形式,有些选项还有/前缀(MSVC 风格)。按 ID 分类后,Clang 的解析器就能正确处理所有这些语法变体,分类逻辑只需要关心“这个选项是什么”,不需要关心“它是怎么拼写的”。为什么与用户内容相关的选项不参与工具链缓存键?
这是分类设计的核心收益。
-I和-D不会改变编译器驱动输出的系统路径、目标三元组或语言默认设置。将它们排除在缓存键之外,使缓存键数量从“每个文件一个”降到“每种配置一个”。一个拥有数万个文件的项目通常只有几十个不同的缓存键,启动时只需要几十次子进程调用。为什么搜索路径去重必须精确匹配 Clang 的行为?
如果语言服务器的头文件搜索顺序与实际编译器不一致,
#include可能解析到不同的文件——同名头文件存在于不同目录时,搜索顺序决定了使用哪一个。#include_next的语义更是直接依赖搜索目录的去重结果。严格匹配确保语言服务器看到的代码与编译器完全一致。为什么 GCC 探测要分两步,而不是直接解析 GCC 的
-v输出?Clang 前端需要 GCC 的目标三元组和安装路径,才能正确定位 GCC 的标准库头文件。直接解析 GCC 的
-v输出虽然可行,但会引入额外的文本解析逻辑,且容易因为不同 GCC 版本输出格式变化而出错。通过--target=和--gcc-install-dir=将信息注入 Clang 驱动,让 Clang 自身完成搜索路径组装,更可靠。为什么编译器家族识别基于可执行文件名而不是路径解析?
编译器驱动的行为受调用名称影响。例如,
/usr/bin/clang++通常是/usr/lib/llvm-20/bin/clang的符号链接,但以clang++名义调用时会自动启用 C++ 模式并链接 C++ 库。如果在识别前用realpath解析到实际路径,会丢失调用名称携带的语义信息。类似地,如果arm-none-eabi-g++被解析成某个通用 GCC 二进制的路径,交叉编译的上下文就会丢失。没有 CDB 条目的文件怎么处理?
头文件会先通过依赖图寻找包含它的源文件,并借用该文件的命令(参见 编译上下文)。否则,由第一条带
default_command的匹配规则提供命令——要描述所有文件共用同一套标志的项目,或是一个临时目录,用的就是这种方式。这些都没有的文件会借用附近翻译单元的命令(参见推断);只有连可借用的翻译单元也没有的文件才使用内置命令:按文件的语言选择clang或clang++ -std=c++20,并注入资源目录(resource dir),这样基本的语义分析仍然可用,同时会有一条提示说明该命令是猜测得到的。
已知限制
发现流程从不扫描整棵目录树。 嵌套项目的数据库要等到它的某个文件被打开时才加载;在那之前,它的翻译单元不会被索引。用一条规则指明这份数据库(或写明
default_command),就能从一开始覆盖它。借来的命令只服务于编辑器。 按推断出的命令编译的文件不是构建的成员:它不会被后台索引,也不能作为头文件的宿主。把它列入某份数据库,或纳入某条
default_command规则,它才会成为成员。部分编译器家族支持不完整。 Intel 编译器(
icc、icx、dpcpp)虽然能被识别,但目前会转入通用的 Clang 驱动路径,没有专门的探测逻辑,因此可能无法正确发现其特有的系统路径。NVCC 已有专门的探测逻辑(解析nvcc --dryrun输出并将其转换为 clang CUDA 调用),但仅支持以 GCC 或 Clang 作为宿主编译器——尚不支持由 nvcc 驱动 MSVCcl,多架构命令也只解析最新的架构。SearchConfig 不支持所有搜索路径选项。
-cxx-isystem(仅 C++ 模式生效的系统目录)、-iwithsysroot(在路径前拼接 sysroot)和 HeaderMap 支持尚未实现。这些选项在实际项目中不常见,但可能出现在某些 Apple 工具链或交叉编译工具链中。配置规则的全局影响。
clice.toml中的[[rules]]可以向编译命令追加或移除选项。如果用户修改了影响所有文件的规则(例如追加一个全局的-I),所有文件的编译配置都会改变,可能触发全量重索引。目前没有机制检测哪些规则变更实际影响了哪些文件。MSVC 风格选项解析。 在非 Windows 系统上,必须特别处理 MSVC 风格选项前缀(
/U、/D、/I),避免 Unix 绝对路径(如/Users/...)被误解析为 MSVC 选项。目前通过根据驱动名称动态调整选项可见性来解决,但边界情况仍可能存在。编译器启动器不被识别。 被启动器包装的 CDB 命令(
ccache g++ ...、sccache clang++ ...)在解析时会将启动器视为编译器,导致家族识别和工具链探测以错误的二进制文件为目标。请从编译数据库的命令中去掉启动器,或用配置规则覆盖相关选项。
