Skip to content

命令解析

背景

C++ 语言服务器需要知道如何编译项目中的每一个文件。这些信息来自编译数据库(compilation database,简称 CDB),通常是构建系统生成的 compile_commands.json 文件。CDB 中的每条记录包含一个源文件路径和一条编译命令,例如:

bash
g++ -std=c++20 -O2 -fPIC -I../include -DNDEBUG -c src/foo.cpp

这条命令记录了构建系统在实际编译时调用编译器的方式。然而,语言服务器不能直接使用它,原因有三个。

第一,这是驱动级命令,不是前端命令。 上面的 g++ 是编译器驱动程序(driver),它负责选择正确的前端、链接器和标准库。语言服务器实际使用的是 Clang 的前端(cc1),而从 g++ 到 cc1 的转换涉及大量隐式操作:确定目标三元组(target triple)、注入系统头文件搜索路径、设置默认语言标准等。这些信息在 CDB 中不会显式出现——它们是编译器隐式提供的。

第二,命令中混杂了语义无关的选项。 -O2-fPIC 只影响代码生成,不影响语义分析——语言服务器不做代码生成,也不需要这些选项。类似地,-c(编译模式)和 -o(输出文件)是构建产物相关的指令,对语言服务器毫无意义。如果不加过滤地传给前端,它们会增加不必要的复杂度,甚至引起错误。

第三,大型项目中的命令高度冗余。 一个拥有上万源文件的项目,绝大多数文件使用相同的编译器和语义选项,只有 include 路径和宏定义因文件而异。不做去重意味着每个文件都要独立查询工具链,浪费内存和启动时间。

这三个问题中,最影响用户体验的是第一个:隐式信息的缺失。当语言服务器无法正确获取系统头文件路径时,用户会看到标准库头文件报错——#include <vector> 报 "file not found",或者 GCC 内置的 type traits 被标记为未声明标识符。在 clangd 的 issue 中,这类问题长期占据最高频率(clangd#1262clangd#1691)。

clangd 的解决方案是 --query-driver 参数:用户手动指定哪些编译器需要探测,clangd 再去查询这些编译器获取系统路径。这个方案有两个问题:首先,它是手动的——用户必须知道自己的项目使用了哪个编译器,还要正确配置 glob 模式。其次,它作为一个 flag 并不独立可用——它依赖 CDB 中已有的命令来触发探测(clangd#1219)。对于交叉编译、嵌入式开发等场景,用户经常需要反复调试才能让 clangd 正确识别工具链。

clice 将整个命令处理流程自动化:从 CDB 中读取命令后,自动识别编译器家族(GCC、Clang、MSVC 等),自动探测工具链信息,并通过多级去重将启动开销降到最低。用户不需要手动配置任何工具链相关的参数。

设计

命令处理的核心任务是将 CDB 中的原始驱动命令转换为 Clang 前端可消费的 cc1 参数。这个转换涉及四个概念层次:参数分类、命令分离、工具链探测、搜索路径提取。

参数分类

加载 CDB 时,每个编译选项被分为四类:

  • 丢弃(Discarded):与构建产物相关的选项,语言服务器不需要。包括输出文件(-o)、编译模式(-c)、依赖扫描(-M 系列)、PCH 构建(-emit-pch)、C++20 模块(-fmodule-file 等,由语言服务器自行管理)。

  • 仅代码生成(Codegen-only):只影响代码生成后端、不影响语义分析的选项。包括位置无关代码(-fPIC)、栈保护(-fstack-protector)、帧指针(-fomit-frame-pointer)、调试信息(-g 系列)、LTO 等。这些不会改变 AST 或诊断结果。

    注意:看起来像代码生成选项的 -O-fsanitize= 不在此列。-O 会定义 __OPTIMIZE__ 宏,-fsanitize=address 会影响 __has_feature(address_sanitizer) 的结果。它们改变预处理器状态,属于语义选项。

  • 用户内容(User-content):每个文件可能不同的选项,但不影响工具链探测结果。包括 include 路径(-I-isystem-iquote-idirafter)、宏定义(-D-U)和强制包含(-include)。这些选项的含义是"这个特定文件额外需要什么"。

  • 语义选项(Semantic):剩余的所有选项,它们影响编译语义并在工具链探测中起作用。例如 -std=c++20-Wall-target-march= 等。

分类基于 Clang 自身的选项表(OptTable),按选项 ID 进行判断,而不是按字符串匹配。

命令分离

分类完成后,每条编译命令被拆分为两部分:

  • CanonicalCommand:驱动程序路径 + 所有语义选项。代表"编译器的身份和语义配置"。
  • Patch:所有用户内容选项。代表"这个文件额外需要的 include 路径和宏定义"。

两者组合加上工作目录构成 CompilationInfo,是一个文件完整编译配置的抽象表示。

这个分离的核心目的是工具链探测的缓存效率。工具链探测需要实际调用编译器驱动(如运行 g++ -dumpmachineclang++ -###),耗时通常在 100ms 以上。探测结果只取决于驱动程序和语义选项——用户内容选项(-I-D)不会影响驱动输出的系统路径或目标三元组。因此,无论文件有什么不同的 -I 路径,只要语义选项相同,就可以共享同一个探测结果。

在实际项目中,上万个文件可能只有几十个不同的 CanonicalCommand,这意味着工具链只需要探测几十次而非上万次。

CanonicalCommandCompilationInfo 都通过 ObjectSet 去重——内容相同的实例在内存中只存在一份,由指针共享。字符串参数通过 StringSet 内部化,保证指针稳定且可直接比较。

编译数据库

CompilationDatabase 负责加载 compile_commands.json,将每条记录解析、分类、去重后存储为 CompilationEntry(文件路径 ID → CompilationInfo)。所有条目按文件路径 ID 排序,支持二分查找。

查找时,CompilationDatabaseCompilationInfo 组装为 CompileCommand——这是命令处理流水线的最终输出,包含完整的编译选项和源文件路径,可直接提交给工具链探测或 Clang 前端。

对于没有 CDB 条目的文件(例如用户打开了一个不在项目中的文件),CompilationDatabase 会合成一个默认命令——根据文件扩展名选择 clangclang++ -std=c++20

CompilationDatabase 还提供了按配置分组的能力:ConfigGroup 将共享相同 CompilationInfo 的文件聚合在一起。这是依赖扫描中提取搜索路径配置的正确粒度——不同的 -I 路径产生不同的分组。对于工具链探测,粒度更粗(用户内容选项不影响探测结果),因此 Toolchain 会在 ConfigGroup 的基础上进一步去重。

配置规则

除了 CDB 本身的编译命令,用户可以通过 clice.toml 中的 [[rules]] 配置追加或移除编译选项。每条规则包含文件匹配模式(glob)和要追加(append)/移除(remove)的选项列表。

在查找文件的编译命令时,匹配的规则会被应用到 CDB 命令之上——先从基础命令中移除指定的选项,再追加新选项。这使得用户可以项目级地微调编译参数,而不需要修改构建系统的输出。

工具链

Toolchain 负责将驱动级命令转换为 cc1 参数。它的设计围绕两个核心能力:

编译器家族识别。 Toolchain 通过可执行文件名识别编译器家族——CompilerFamily 枚举包括 GCC、Clang、MSVC、ClangCL、NVCC、Intel、Zig。识别规则处理了各种命名变体:版本后缀(clang++-17)、架构前缀(arm-none-eabi-g++)、Windows 的 .exe 后缀等。家族信息决定了后续使用哪种探测策略。

缓存策略。 探测结果以 (驱动路径, 文件扩展名, 非用户内容选项) 为键缓存。文件扩展名参与缓存键,因为 .c.cpp 可能触发不同的驱动规则。失败的探测也会被缓存(负缓存),避免对同一个不存在的编译器重复尝试。

搜索路径

SearchConfig 从 cc1 参数中提取头文件搜索路径,组织为四段式结构:

  1. Quoted-iquote):#include "foo.h" 的搜索路径
  2. Angled-I):#include <foo.h> 的搜索路径
  3. System-isystem-internal-isystem 等):系统头文件路径
  4. After-idirafter):在系统目录之后搜索的路径

这个四段模型对应 Clang 内部的搜索布局。段内路径会去重(从 Angled 段开始),去重算法复制了 Clang 的行为:如果同一路径出现在 Angled 和 System 两个段中,保留 Angled 中的那个。这确保了 #include_next 的正确性。

SearchConfig 是 include 路径补全、include 路径解析、依赖图构建等功能的基础输入。

实现

加载与解析

CDB 加载使用 simdjson 流式解析 JSON,逐条处理:

  1. 读取每条记录的 directoryfilearguments(或 command)字段
  2. 过滤非 C/C++ 文件(如 .rc.asm.def
  3. 将相对文件路径解析为绝对路径
  4. arguments 字段中的每个选项进行分类,分别放入 canonical 和 patch
  5. 将 include 路径选项中的相对路径绝对化(基于 directory 解析)
  6. 通过 ObjectSet 去重 CanonicalCommandCompilationInfo
  7. 所有条目按文件路径 ID 排序

解析过程中还处理了一个特殊情况: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 cczig c++),探测时需要特殊处理。

外部驱动的版本可能比 clice 内嵌的 LLVM 版本更新,输出的 cc1 参数中可能包含 clice 不认识的选项。解析时会将未知选项静默丢弃,保证兼容性。

MSVC / ClangCL:通过 --driver-mode=cl 指令切换 Clang 驱动到 MSVC 兼容模式,然后用 Clang 驱动获取 cc1 参数。

探测完成后,会从结果中移除临时探测文件的路径和模块输出相关的选项(它们引用了已删除的临时文件)。如果 clice 的 resource dir 与探测结果中的不一致,还会替换所有相关路径,确保前端使用匹配版本的内置头文件。

启动预热。 在服务器启动的依赖扫描阶段,会收集所有唯一的工具链缓存键并发起并行探测。探测子进程的管道读取和进程等待也是并发执行的,避免管道阻塞导致的死锁。预热完成后,后续所有的工具链查询都命中缓存。

搜索路径提取

工具链探测得到 cc1 参数后,搜索路径提取遍历这些参数,将 include 路径选项按类型分入四个段。所有路径被解析为绝对路径并规范化(消除 ...)。

-iprefix / -iwithprefix / -iwithprefixbefore 三个选项需要按出现顺序配合处理:-iprefix 设置前缀,后续的 -iwithprefix 将前缀拼接到路径前再放入 After 段,-iwithprefixbefore 则放入 Angled 段。

四段拼接完成后,从 Angled 段开始去重。Quoted 段不参与去重——同一路径同时出现在 Quoted 和 Angled 中是合法的,两者都会被保留。这复制了 Clang 内部的行为。

FAQ

  • 为什么按选项 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 中没有条目的文件怎么处理?

    合成一个默认命令。根据文件扩展名选择 clangclang++ -std=c++20,并注入 resource dir。这确保即使文件不在 CDB 中,基本的语义分析仍然可用。对于头文件,还会尝试通过依赖图找到包含它的源文件,使用该源文件的编译命令作为上下文(详见编译上下文)。

已知局限

  • 部分编译器家族支持不完整。 NVCC 和 Intel 编译器(iccicxdpcpp)虽然被识别,但目前回退到通用的 Clang 驱动路径,没有专门的探测逻辑。这意味着这些编译器的特殊系统路径可能无法被正确发现。

  • SearchConfig 不支持部分搜索路径选项。 -cxx-isystem(仅 C++ 模式生效的系统目录)、-iwithsysroot(拼接 sysroot 前缀)和 HeaderMap 支持尚未实现。这些选项在实际项目中不常见,但可能在特定的 Apple 或交叉编译工具链中出现。

  • 配置规则的全局影响。 clice.toml 中的 [[rules]] 可以向编译命令追加或移除选项。如果用户修改了影响所有文件的规则(例如追加一个全局的 -I),所有文件的编译配置都会改变,可能触发全量重索引。目前没有机制检测哪些规则变更实际影响了哪些文件。

  • MSVC 兼容模式的选项解析。 在非 Windows 系统上,需要特别处理 MSVC 风格的选项前缀(/U/D/I),避免 Unix 绝对路径(如 /Users/...)被误解析为 MSVC 选项。目前通过根据驱动名称动态调整选项可见性来解决,但边界情况仍可能存在。