跳到正文

编译上下文 ​

背景 ​

#include 的本质是文本替换:头文件的含义取决于包含它之前的预处理器状态。因此同一个文件可能编译出截然不同的结果——源文件在 Debug 和 Release 命令下各有不同,头文件在每个包含它的文件下也各有不同。

语言服务器在服务一个文件时必须选定其中一个上下文,所有响应都取决于这一选择。现有服务器都是隐式选择的,用户既看不到也改不了;一旦选错,就会出现大片误报错误。在 clangd 中,这表现为几个长期存在的问题:不能自包含(self-contained)的头文件无法正常工作(clangd#45),头文件的编译命令靠文件名猜测且常常猜错(clangd#1505),而当一个文件有多条编译命令时用户无法自行选择(clangd#681)。

clice 将编译上下文作为一等概念:每次编译、每条索引记录和每项响应都明确归属某个上下文,用户也可以列出文件的所有上下文并在其间切换。

什么是编译上下文 ​

编译上下文是还原文件编译状态所需的全部信息。

  • 源文件的上下文是它的一条编译命令。在编译数据库中多次出现的文件(如 Debug 与 Release、两个不同平台)拥有多个上下文。
  • 自包含头文件(即自行包含了所需全部依赖的头文件)的上下文是某个包含它的源文件的编译命令:头文件在该命令的搜索路径和语言标准下独立编译。
  • 非自包含头文件(例如必须在包含方定义 X 之后才有意义的 X-macro 列表)的上下文是头文件上下文:包含它的宿主源文件,加上它在宿主包含树中的包含位置。没有头文件保护(include guard)的头文件可能被同一宿主包含多次,且每次处于不同的预处理状态,因此包含位置会按序编号,由“宿主文件 + 编号”唯一标识一个上下文。

选择上下文 ​

文件按以下优先级采用第一个适用的上下文编译:

  1. 用户的显式选择,通过 clice/switchContext 设定并保存在项目缓存目录中;被指定在某个宿主中编译的头文件始终使用该宿主的前缀。若所选上下文暂时失效(例如宿主不再包含该文件,或对应条目已移出编译数据库),该选择会暂时搁置,条件重新满足后再次生效。
  2. 文件自身的编译命令(当该文件是构建中的翻译单元时):它在编译数据库中的条目(若有多条则取第一条,直到用户另行选择)、覆盖它的 default_command,或临时成员(provisional member)的命令。
  3. 宿主文件:构建中实际编译且包含该头文件的源文件,按命令解析所述的规则排序。自包含头文件借用宿主的编译命令;非自包含头文件同时使用宿主的编译命令与合成前缀(见下文)。
  4. 规则的 default_command,适用于没有任何翻译单元充当宿主的头文件。
  5. 借用的命令:针对尚无任何描述的文件(如新建的源文件、未被任何文件包含的头文件),借用邻近翻译单元的编译命令(见命令解析)。
  6. clice 内置的后备命令,使孤立文件仍能获得基本的语言服务响应。

编辑器使用以下 clice 请求操作上下文:

请求作用
clice/queryContext列出文件的可用上下文(源文件的数据库条目、头文件的宿主文件),最优项排在最前并支持分页。编译效果完全相同的上下文只列出一次。
clice/currentContext返回文件当前编译所用的上下文、是否由用户手动选定,以及所使用的编译命令及其来源。
clice/switchContext选定列表中的某个上下文;文件无需关闭即可在该上下文中重新编译。
clice/resetContext清除手动选择,恢复为 clice 自动选定的上下文。
clice/showOtherContexts控制当前上下文跳过的代码区域是否展示其他上下文在该处编译的信息(取自索引)。

在选定的上下文中,被预处理器跳过的分支会变暗显示,因此切换上下文的效果一目了然。

构建配置 ​

文件的上下文按单个文件选择,而构建配置按整个项目选择。规则可以带有 configuration 标签,所有标签共同构成配置菜单,同一时刻服务器中只有一个标签处于活动状态:带该标签的规则生效,未带标签的规则始终生效。活动配置在启动时按以下顺序解析一次:--configuration 命令行参数、编辑器持久化保存的选择、default_configuration,最后是第一个声明的标签。

切换构建配置会重启服务器,这是有意为之:一个服务器只装载一套配置的全部内容——编译数据库、依赖图、上下文和索引——换到另一套配置,本就是每次会话都要经历的一次冷启动,而且能用上那套配置自己的缓存。每种配置都有独立的索引数据库,因此切换时绝不会混用两套配置的索引;切回原配置时,也只需重新索引期间磁盘上发生变动的文件。

判断头文件是否自包含 ​

大多数头文件都是自包含的,为它们合成前缀纯属浪费。clice 会先直接尝试:在宿主的编译命令下单独编译该头文件。若无报错,则判定为自包含。若有报错,则带上宿主的前缀再编译一次,以错误较少的那次为准:宿主上下文中错误更少则判定为非自包含,错误数相同则仍视为自包含。这样无需事先罗列包含方提供了什么——凡是宿主上下文能修好的错误都算数。按惯例用作代码片段的扩展名(.def、.inc、.inl、.tpp 等)直接跳过这项检查。

判定结果会连同检查过程中读取的文件一起缓存,只要这些文件未变便一直有效。检查读取过的其他文件发生变动会重新触发检查。头文件自身的编辑不会改变判定——输入过程中绝不重新检查,否则敲到一半的标识符会导致判定来回跳变——唯一的例外是:已判定为自包含的头文件在存在编译错误时保存,会重新检查。

在宿主上下文中编译头文件 ​

clice 直接编译目标头文件本身,并用 -include 强制注入一段合成的前缀(prefix)。沿着宿主的包含树向下走到目标头文件,clice 从沿途每个文件中截取包含下一个文件之前的全部内容,每段前面加上 #line,使源码位置仍指向原始文件:

cpp
// main.cpp
#include <vector>
#define DEBUG 1
#include "utils.h"
cpp
// utils.h
#pragma once
#include <string>
#include "math.h"

打开 math.h 时,编译会强制注入如下前缀:

cpp
#line 1 "main.cpp"
#include <vector>
#define DEBUG 1
#line 1 "utils.h"
#pragma once
#include <string>

前缀仅存在于内存中,各片段分别挂在被截取文件所在的目录下,因此其中的相对路径查找结果与在原文件中完全一致。位于 #if 块(如头文件保护)内部的截断会自动补齐:在前缀末尾闭合这些条件块,并在后缀中重新打开。位于包含方左花括号内部的头文件(如枚举项列表、类体)还会额外获得一段用于闭合花括号的后缀(suffix)。包含链上的任一文件发生变动都会使该上下文作废,下次编译时再重新合成。

之所以采用 -include,是为了利用 Preamble 的 PCH:强制包含的内容在头文件正文之前编译,因此整段前缀都会进入该头文件的 PCH,编辑头文件时只需重新编译其正文。

索引中的上下文 ​

头文件的索引条目因上下文而异。头文件的索引分片将每个上下文的条目存为一个变体(variant),查询时取所有有效变体的并集,从而能同时查到头文件中 #ifdef 两个分支里的引用。详见符号索引。

常见问题 ​

  • 通过 -include 编译头文件与在宿主中直接编译完全等价吗? 在标准定义的范围内完全等价:#line 会让 __FILE__、__LINE__ 和 std::source_location 给出与原文件一致的值。只有两处编译器扩展不同:__INCLUDE_LEVEL__ 为 0,且 __BASE_FILE__ 指向该头文件而非宿主文件。这两点都不影响语言服务的响应结果。

  • 为什么不直接编译宿主文件并在目标头文件末尾停下? 那样虽然无需合成前缀、也能原样保留外层花括号,但目标头文件会落在宿主包含树的中间位置,无法在此处切分 PCH。每次编辑头文件都得把宿主在此之前包含的所有内容重新解析一遍,在大型项目上太慢。把目标头文件本身作为主编译文件,才能将其前缀缓存进 PCH。

已知限制 ​

  • 一个宿主文件只尝试一条编译命令。 若宿主有多条编译命令,只会尝试第一条适配该头文件的命令;仅在宿主其他命令下才会被包含的头文件,会改由其他宿主编译,或者找不到宿主。
  • 通过宏写出的 include 无法截断(如 #include FT_FREETYPE_H):以此方式进入且依赖包含方状态的头文件,只能单独在宿主的编译命令下编译。
  • 通过宏包含头文件的已选宿主在自身发生修改后会暂时失效,直到某次编译重新读取其包含树为止;重新选择一次该宿主也可恢复。
  • 切换构建配置会重启语言服务器。