跳到正文

依赖扫描 ​

背景 ​

语言服务器在启动之初就需要掌握整个项目的 包含关系:

  • 头文件需要宿主文件。 头文件在编译数据库中没有条目;要编译头文件,clice 需要找到包含它的源文件——也就是反向 包含图。
  • 模块需要映射表。 构建模块需要知道每个模块名由哪个文件声明,以及每个文件导入了哪些模块。

获取精确的 包含关系需要运行完整的预处理器——展开所有宏并对所有条件求值——在数万个文件的项目上这需要数分钟。clangd 在后台索引编译文件的过程中逐步构建 包含图,因此在可能耗时数十分钟的索引完成之前,打开头文件可能找不到宿主文件并报出虚假错误。clice 改用自研的快速扫描器,启动后数秒内即可建立可用的 包含图。

设计 ​

所有包含方共享的超集 ​

扫描器不运行预处理。由于不展开宏也不对条件求值,一个文件包含哪些文件不再取决于谁包含了它,因此每个文件只需扫描一次,结果由所有包含方共享:总工作量取决于不重复的文件数,而非(源文件,头文件)组合数。代价是每个 #if 的两个分支都会计入,得到真实 包含关系的超集。就该图的用途而言,超集是安全的:查找宿主文件时可能会多出候选者,但绝不会漏掉真正的宿主文件——且每个候选者在充当头文件宿主前,都会对照其真实编译结果校验。

为什么这么快 ​

  • 指令扫描。 Clang 的依赖指令扫描器(dependency directive scanner)只读取文件中的预处理指令行,每个文件仅需微秒级时间,直接产出原始 包含名称("foo.h"、<vector>)。
  • 带缓存的路径解析。 包含名称基于缓存的目录列表解析,而非逐个对候选路径调用 stat,且尖括号形式的 include 在每种编译配置和名称组合下只解析一次。
  • 分波推进。 扫描从构建中的所有翻译单元及其编译命令强制包含的文件(-include、-imacros)出发,解析它们包含的目标,扫描新发现的头文件,如此循环直至不再出现新文件。上一波执行路径解析的同时,下一波的文件已在并行读取。

依赖图 ​

  • 正向边:每个文件包含了哪些文件。翻译单元在其每条编译命令下分别解析(不同命令的搜索路径可能不同);头文件则在最先到达它的那条命令下解析。
  • 反向边,批量构建,用于从头文件向上回溯包含它的翻译单元。
  • 强制包含与普通 包含边分开存储,因为源码中没有指令写出它们;查找宿主文件时同样会沿它们向上回溯。
  • 模块名称映射到声明它们的模块单元(包括模块接口和模块分区)。如果指令扫描器无法直接读出模块声明(例如声明位于 #if 内),则会仅对该文件运行预处理器,直至读到模块声明为止。

每个文件的扫描结果都与内容版本关联,只要内容未变即可直接复用。文件发生变更后会重新扫描,并按启动扫描到达它时的同样方式重新解析,替换原有的边并继续扫描新到达的头文件。

按需获取精确结果 ​

在必须取得精确结果的地方——例如每次构建翻译单元前确定它导入了哪些模块(见编译任务图)——会对该翻译单元运行预处理器。快速扫描负责判定何时可以跳过这一步:它会记录哪些文件含有模块语法;若一个翻译单元通过 包含链到达不了任何此类文件,则必然没有导入任何模块,因为宏无法展开出 import。

后台索引使用完整预处理器编译每个翻译单元,并在索引中记录精确的包含树;查找宿主文件时会读取这些包含树,核实候选者的编译是否确实进入了目标头文件。快速扫描生成的依赖图则始终负责筛选候选者。

设计决策 ​

  • 为什么用超集而不是精确结果? 精确结果需要数分钟,而超集只需数秒,且不会遗漏任何宿主文件。
  • 为什么不等后台索引完成? 在大型项目上后台索引可能运行数十分钟,在此期间头文件都会出错。快速扫描可以立即给出可用的依赖图,再由后台索引逐步补齐精确信息。
  • 为什么分波推进? 头文件不在编译数据库中,只能靠解析源文件的 include 逐步发现,每一波的解析结果正好给出下一波要扫描的文件。

已知限制 ​

  • 宏形式的 include(#include MACRO_NAME)无法在扫描阶段解析,留待实际编译处理。
  • 每个条件分支都会计入,因此依赖图比单次编译的实际包含图更大,可能把实际上从未包含的文件也算作关联文件。
  • 头文件在最先到达它的编译命令下解析。 若两个翻译单元通过不同的包含路径到达某个头文件,在第二个翻译单元完成索引前,该头文件无法从第二个翻译单元获得宿主上下文。
  • 扫描直接读取磁盘。 在打开的缓冲区中新输入的 #include 在保存之前不会在依赖图中新增边。