diff --git a/.vscode/launch.json b/.vscode/launch.json index 50d3b49897..d0c1aba6c3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -31,7 +31,7 @@ "CLICE_MODE": "pipe" }, "outFiles": ["${workspaceFolder}/editors/vscode/dist/**/*.js"], - "preLaunchTask": "npm: watch vscode ext" + "preLaunchTask": "npm: compile vscode ext" }, { "name": "VSCode Extension (socket)", @@ -47,7 +47,7 @@ "CLICE_MODE": "socket" }, "outFiles": ["${workspaceFolder}/editors/vscode/dist/**/*.js"], - "preLaunchTask": "npm: watch vscode ext" + "preLaunchTask": "npm: compile vscode ext" }, { "type": "lldb", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 86fc33757a..69f49f6e4f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,6 +11,17 @@ }, "problemMatcher": [] }, + { + "label": "npm: compile vscode ext", + "type": "shell", + "command": "pnpm", + "args": ["run", "compile"], + "options": { + "cwd": "${workspaceFolder}/editors/vscode" + }, + "dependsOn": "npm: install vscode deps", + "problemMatcher": [] + }, { "label": "npm: watch vscode ext", "type": "shell", diff --git a/docs/en/design/compilation-context.md b/docs/en/design/compilation-context.md index f1fd23b8a3..070ade15e5 100644 --- a/docs/en/design/compilation-context.md +++ b/docs/en/design/compilation-context.md @@ -84,19 +84,35 @@ The include position is important because the same header may be included multip When a file needs to be compiled, the compilation context is automatically determined by the following priority: -1. **User selection takes priority**. If the user has actively selected a compilation context via `clice/switchContext`, that selection is used. +1. **User selection takes priority**. If the user has actively selected a compilation context via `clice/switchContext`, that selection is used. Choices are persisted in the workspace cache and restored automatically when the file is opened in a later session. -2. **Direct CDB lookup**. If the file has an entry in the compilation database, that entry's compilation command is used. +2. **Direct CDB lookup**. If the file has an entry in the compilation database, that entry's compilation command is used. In multi-configuration projects where one file has several commands, the first one is used by default; the user can switch to another entry by command hash. -3. **Dependency graph lookup**. If the file is not in the CDB (the common case for headers), the DependencyGraph's include relationships are used to find a source file that includes it, and that source file's compilation command is borrowed. For self-contained headers, borrowing the compilation command is sufficient; for non-self-contained headers, prefix code must also be synthesized to restore the preprocessor state. +3. **Dependency graph lookup**. If the file is not in the CDB (the common case for headers), the dependency graph's include relationships are used to find a source file that includes it, and that source file's compilation command is borrowed. Host candidates are ranked by relevance: a source sharing the header's stem (`utils.h` -> `utils.cpp`) wins, then sources in the same directory, then path proximity, with a lexicographic tie-break for determinism. For self-contained headers, borrowing the compilation command is sufficient; for non-self-contained headers, prefix code must also be synthesized to restore the preprocessor state — the distinction is made automatically, see the next section. + +4. **Command transfer heuristics (reserved, not yet implemented)**. For a file with neither a CDB entry nor any host, infer the best command from the CDB. Two strategies are planned: first, build a reverse table from the header search directories (`-I` etc.) of compile commands to the commands using them, and walk up from the file's parent directory — a header living under some command's search path borrows that command, and its own includes then resolve correctly (the typical case: a freshly created header not yet included by anything); second, nearest-path inference, borrowing from the CDB entry closest by path. This turns clangd's command interpolation into an explicit, observable tier instead of a hidden guess. The command source labeling already reserves an `Inferred` slot, and suspicious errors from inferred commands come with guidance diagnostics. + +5. **Default command fallback**. When everything above falls through, an isolated file compiles with a synthesized default command, so basic language services keep working. Beyond automatic resolution, clice also provides three LSP extension requests that let users explicitly query and switch compilation contexts: -**`clice/queryContext`** lists all available compilation contexts for a file. For headers, it returns all source files that can serve as hosts; for source files, it returns all matching CDB entries, using key flags from the compilation command (`-D`, `-std`, `-O`, etc.) as human-readable descriptions to distinguish them. Results support pagination. The returned context list is deduplicated — if two contexts are judged to produce the same compilation result, they are merged into a single entry. Otherwise, a widely-included header like `` could list thousands of contexts. +**`clice/queryContext`** lists all available compilation contexts for a file. For headers, it returns the source files that can serve as hosts, ranked by relevance; for source files, it returns all CDB entries, using key flags from the compilation command (`-D`, `-std`, `-O`, etc.) as human-readable descriptions, each carrying a command hash identifying the entry. Results support pagination. For self-contained headers and source files, the context list is deduplicated by the **hash of the canonicalized compile arguments**: two contexts whose canonical arguments are identical produce identical compilation results, so only the best-ranked representative is kept — otherwise a widely-included header like `` could list thousands of contexts. Non-self-contained headers are not merged this way: different hosts synthesize different prefixes, so every host is a distinct context. When a guard-less header is included several times by one host, each include position is returned as a separate entry distinguished by an occurrence number. + +**`clice/currentContext`** returns the currently active user selection (host + occurrence, or command hash). If the user has not actively switched (i.e., the automatically selected default context is in use), it returns empty. + +**`clice/switchContext`** switches to the user's chosen new context. The choice is validated: the host must actually (transitively) include the header, the occurrence must be in range, and the command hash must correspond to a real CDB entry of the file, otherwise the request fails. + +Besides the requests, after every compile the server pushes a **`clice/inactiveRegions`** notification carrying the file's preprocessor-inactive regions (bodies of untaken `#if` branches) under the current compilation context. Editors render them dimmed; a context switch recompiles and flips the regions — the most immediate visual feedback of switching. -**`clice/currentContext`** returns the currently active user selection. If the user has not actively switched (i.e., the automatically selected default context is in use), it returns empty. +## Automatic Self-Containedness Detection -**`clice/switchContext`** switches to the user's chosen new context. +The vast majority of headers are self-contained, and synthesizing a prefix for them is pure waste — it requires computing the include chain, reading every file on it, and pulling in a large number of preceding dependencies. clice therefore uses a **try-then-fall-back** strategy for headers without a CDB entry: + +1. **Trial compile**: treat the header as self-contained and compile it directly with the borrowed host command (no prefix). +2. **Diagnostic scoring**: if the trial's diagnostics hit a strictly curated set of "missing context" signals — unknown type name, undeclared identifier, unterminated conditional directive — the header is judged non-self-contained. The set is deliberately narrow: a false positive merely costs one pointless prefix synthesis, while misclassifying ordinary in-progress typing errors would cause meaningless recompiles. +3. **Fallback recompile**: after the verdict, the header is recompiled with a synthesized prefix. The trial's error diagnostics are never published to the editor; the user only sees the final result. + +Files with `.def` or `.inc` extensions (the X macro convention) skip the trial and are treated as non-self-contained directly. Only the "needs context" verdict is persisted in the workspace cache — it is the expensive side, and restarts should not repeat the trial; "self-contained" remains a session-local impression whose re-validation is free (the trial _is_ the regular compile). Re-evaluation timing is deliberately split: **dependency changes** (a chain file saved, a file the header includes changed, external on-disk modifications) re-run the trial, while **user typing** never does — otherwise a half-typed unknown type name would keep triggering pointless prefix synthesis. Saving the header itself also resets the persisted verdict. ## Header Context in Practice @@ -104,7 +120,7 @@ The header context is conceptually clear (host source file + include position), > The prefix synthesis discussed here only applies to headers the user has opened. Headers on disk that are not open do not need special handling — they are included and processed normally when each source file is compiled. The indexing system collects symbol information from headers as part of each source file's indexing pass, and MergedIndex merges the index data produced for the same header under different source files (see [Index Design](symbol-index.md) for the merging mechanism). -clice uses **prefix synthesis + `-include` injection**: based on the host source file and include position from the header context, it extracts all content before the target header along the include chain, synthesizes it into prefix code, writes it to a prefix file on disk, and then injects that file into the compilation command via Clang's `-include` flag. The core advantage of this approach is its natural compatibility with PCH optimization — the prefix file's content is the target header's preamble, which can be compiled into a PCH and cached. When the user subsequently edits the header body, the large volume of header inclusions in the prefix does not need to be reprocessed each time. Detailed rationale for this design choice is in the FAQ section below. PCH construction, caching, and invalidation mechanisms are described in [Incremental Compilation Design](incremental-parse.md). +clice uses **prefix synthesis + `-include` injection**: based on the host source file and include position from the header context, it extracts all content before the target header along the include chain, synthesizes it into prefix code, writes it to a prefix file on disk, and then injects that file into the compilation command via Clang's `-include` flag. The core advantage of this approach is its natural compatibility with PCH optimization — `-include`'d files are processed through Clang's predefines buffer before the main file, so when the preamble PCH is built, the prefix content is baked into the **same** PCH as the header's own preamble region; even a header with no directives of its own (an X macro style `.def` file) gets a PCH built for its prefix. On PCH reuse Clang validates and subsumes matching `-include`s, so the prefix is never processed twice. PCHs are cached by content key (preamble text + canonical flags), so files with identical prefixes automatically share one. When the user subsequently edits the header body, the large volume of header inclusions in the prefix does not need to be reprocessed each time. Detailed rationale for this design choice is in the FAQ section below. PCH construction, caching, and invalidation mechanisms are described in [Incremental Compilation Design](incremental-parse.md). The synthesis process has four stages. The following example illustrates the process. Suppose the project has these files: @@ -153,7 +169,11 @@ clang -std=c++17 -Iinclude -include cache/header_context/a1b2c3d4.h math.h Clang processes the `-include` prefix file first, then compiles `math.h`. The effect is that `math.h` is compiled as if at the `#include "math.h"` position in `main.cpp`, with the correct preprocessor state. -The synthesis result (host ID, prefix file path, content hash) is cached in the Session. As long as the host source file has not changed, subsequent compilations reuse the cached result without re-synthesizing. +Several engineering details matter here. Include directives are resolved against the host command's real search paths and matched by **absolute path**, so same-named headers in different directories cannot be confused; since the prefix file lives in the cache directory, quoted includes with relative paths are rewritten to their resolved absolute paths, otherwise they would be looked up against the wrong base directory. Matching prefers includes outside `#if` blocks (an occurrence in an untaken branch must not shadow the real one); when the cut lands inside `#if` blocks (most commonly an include guard on an intermediate header), balancing `#endif`s are appended so the prefix stays well-formed — the guard condition is still evaluated by the compiler, preserving the semantics. + +Beyond the prefix there is also a **suffix**: the content after the include position (mirrored along the chain — the direct includer's remainder first, the host's last) is synthesized into a suffix file and injected by appending a single `#include` line to the header's buffer at compile time. X macro fragments embedded in enums or function bodies thus see their surrounding braces close — the token stream runs continuously through prefix, main file and suffix. When the cut lands inside `#if` blocks, the prefix closes them with `#endif`s and the suffix reopens the same depth with `#if 1`s, keeping both sides balanced. Includes of the header itself along the chain (other occurrences) are redirected to a disk snapshot of it — the header's own path is remapped to the buffer with the trailing suffix include, so keeping them verbatim would recurse forever. The appended line sits past the editor's visible content, and diagnostics inside the suffix file are never attributed to the header itself. + +The synthesis result (host, prefix file path, suffix file path, content hash, and the include chain with a content snapshot) is cached in the Session. The chain files' content is embedded in the prefix and the compiler never opens them, so regular dependency tracking is blind to them — staleness is handled by the chain snapshot (two-layer mtime + content hash detection): any change to a chain file re-synthesizes the prefix. Saving a chain file forces content re-validation even when its mtime is unchanged. ## Multi-Context in Indexing @@ -198,39 +218,8 @@ If the index only records the result from one context, go-to-definition or find- ## Known Limitations -- **Self-containedness detection is not yet automated**. In practice, the vast majority of headers are self-contained; only a few (X macro lists, internal implementation fragments, etc.) are not. Prefix code synthesis has non-trivial overhead — finding the host, computing the include chain, synthesizing prefix code, and pulling in a large number of preceding dependencies — so it should not be performed for every opened header. The ideal strategy is: treat headers as self-contained by default; if compilation produces a large number of specific error types (e.g., undefined symbols), automatically fall back to non-self-contained mode and recompile with synthesized prefix code. Existing index information (e.g., whether the header produced errors during compilation in other source files) could also help with this judgment. This automatic detection mechanism is not yet implemented. - -- **Source file context switching is not fully implemented**. `clice/queryContext` can already list multiple CDB entries for a source file, but `clice/switchContext` currently only supports switching the host for headers and does not yet support switching between multiple compilation commands for the same source file. This is a known improvement item. - -- **Determinism of host selection**. When multiple hosts are available, the current implementation selects the first source file found by BFS that has a CDB entry. A better strategy would prioritize the host "closest" to the target header — source files in the same directory, files with similar names, etc. — but this requires more refined heuristics. - -- **No suffix handling**. The current prefix synthesis approach only injects content before the include position and does not handle content after the include position. For most headers this is not an issue, but there are two edge cases. - - First, non-self-contained headers like X macros that are embedded inside function bodies — the closing braces after the include position are lost: - - ```cpp - // generate.cpp - void register_all() { - #define X(name, code, msg) register_error(code, msg); - #include "errors.def" // Prefix truncates here, the } below is lost - #undef X - } - ``` - - Second, when `#include` appears inside `#if`/`#ifndef` blocks, truncation produces unclosed conditional compilation directives: - - ```cpp - // wrapper.h - #ifndef USE_LEGACY - #include - #include "target.h" // Prefix truncates here, #ifndef has no matching #endif - #else - #include - #endif - ``` - - These incomplete syntax structures may cause Clang to report errors, but since the errors occur in the prefix file rather than the target header, they are typically filtered out. Suffix injection may need to be introduced in the future to handle these scenarios. +- **Occurrence numbering only covers the direct includer**. By design, a header context is uniquely determined by the host source file plus a position number in its include tree. The current implementation's occurrence only distinguishes multiple includes of the target in its **direct includer** (which covers the typical X macro usage), not a flattened numbering over the host's entire include tree — when the same header is transitively included by one host via different intermediate paths, only the context corresponding to the shortest chain is presented. -- **Recursive includes in prefix synthesis**. The include graph may contain cycles (broken by include guards or `#pragma once`). The dependency scanning stage already handles cycle detection correctly, but the prefix synthesis path has not been fully validated for this scenario. +- **The shortest chain may not match the real first inclusion**. The include chain from host to target is the shortest path in the include graph. In a real compilation the header may first be reached via a different, longer path, and the preprocessor state accumulated before it may differ. In practice this rarely causes observable differences, but strictly speaking the synthesized state may not correspond exactly to any real compilation. -- **PCH cache does not yet support cross-file sharing**. Currently, the PCH cache is keyed by the file's `path_id`, with each file maintaining its own PCH independently. If two different files happen to have identical preamble content, they each build their own PCH independently rather than sharing one. Changing the cache key to the preamble content hash to enable cross-file PCH sharing is a planned optimization. +- **Cross-configuration conditional includes within one TU**. Dependency scanning records include edges per compilation configuration, but host lookup uses the union across configurations. In extreme cases, an include edge that only holds under configuration A could lend configuration B's command to a header. diff --git a/docs/zh/design/compilation-context.md b/docs/zh/design/compilation-context.md index f688501a55..83fb15d5a9 100644 --- a/docs/zh/design/compilation-context.md +++ b/docs/zh/design/compilation-context.md @@ -84,19 +84,35 @@ const char* error_message(int code) { 需要编译一个文件时,按以下优先级自动确定编译上下文: -1. **用户选择优先**。如果用户通过 `clice/switchContext` 主动选择了编译上下文,使用用户的选择。 +1. **用户选择优先**。如果用户通过 `clice/switchContext` 主动选择了编译上下文,使用用户的选择。选择会持久化到工作区缓存,重启后在文件打开时自动恢复。 -2. **CDB 直接查找**。如果文件在编译数据库中有条目,使用该条目的编译命令。 +2. **CDB 直接查找**。如果文件在编译数据库中有条目,使用该条目的编译命令。多配置项目中同一文件有多条命令时,默认取第一条;用户可以通过命令哈希切换到其他条目。 -3. **依赖图查找**。如果文件不在 CDB 中(头文件的常见情况),通过 DependencyGraph 的 include 关系找到一个包含它的源文件,借用该源文件的编译命令。对于自包含头文件,借用编译命令即可;对于非自包含头文件,还需要合成前缀代码来还原预处理器状态。 +3. **依赖图查找**。如果文件不在 CDB 中(头文件的常见情况),通过依赖图的 include 关系找到一个包含它的源文件,借用该源文件的编译命令。宿主候选按相关性排序:同名源文件(`utils.h` 对应 `utils.cpp`)优先,其次是同目录的源文件,再按路径接近程度,最后按字典序保证确定性。对于自包含头文件,借用编译命令即可;对于非自包含头文件,还需要合成前缀代码来还原预处理器状态——两种情况的区分是自动的,见下节。 + +4. **命令转移启发式(预留,尚未实现)**。对既没有 CDB 条目也没有宿主的文件,从 CDB 中推断一条最合适的命令。计划两种策略:其一,为编译命令中的头文件搜索目录(`-I` 等)建立目录到宿主命令的反查表,从文件所在目录逐级向上倒查——落在某条命令搜索路径之下的头文件借用这条命令,它自己的 include 也能正确解析(典型场景:新建的、还没被任何文件包含的头文件);其二,按相邻路径推断,从路径最接近的 CDB 条目借用。相当于把 clangd 的命令插值(interpolation)做成显式、可感知的层级,而不是隐藏的猜测。命令来源标注中已为它预留了 `Inferred` 档位,推断命令产生可疑错误时会附带引导性诊断。 + +5. **默认命令兜底**。以上全部落空的孤立文件,使用一条合成的默认命令编译,保证基本的语言服务可用。 除了自动解析外,clice 还通过三个 LSP 扩展请求让用户可以显式地查询和切换编译上下文: -**`clice/queryContext`** 列出一个文件所有可用的编译上下文。头文件返回所有可以做宿主的源文件列表;源文件返回所有匹配的 CDB 条目,用编译命令中的关键标志(`-D`、`-std`、`-O` 等)做可读描述来区分。结果支持分页。返回的上下文列表会经过去重——如果两个上下文被判定会产生相同的编译结果,它们会合并为一个条目展示,否则像 `` 这种被大量源文件包含的头文件可能会列出几千个上下文。 +**`clice/queryContext`** 列出一个文件所有可用的编译上下文。头文件返回可以做宿主的源文件列表(按相关性排序);源文件返回所有 CDB 条目,用编译命令中的关键标志(`-D`、`-std`、`-O` 等)做可读描述区分,并附带标识该条目的命令哈希。结果支持分页。对自包含头文件和源文件,上下文列表按**规范化编译参数的哈希**去重:参数规范化后相同的两个上下文会产生相同的编译结果,只保留排序最靠前的代表——否则像 `` 这种被大量源文件包含的头文件会列出几千个上下文。非自包含头文件不做此合并:不同宿主合成的前缀不同,每个宿主都是独立的上下文。没有 include 保护的头文件被同一宿主多次包含时,每个包含位置返回一个独立条目,用 occurrence 编号区分。 + +**`clice/currentContext`** 返回当前生效的用户选择(宿主 + occurrence,或命令哈希)。如果用户没有主动切换过(使用的是自动选择的默认上下文),返回空。 + +**`clice/switchContext`** 切换到用户选择的新上下文。切换时会校验选择的合法性:宿主必须确实(传递地)包含该头文件,occurrence 必须在范围内,命令哈希必须对应文件的真实 CDB 条目,否则返回失败。 + +除请求外,每次编译完成后服务器会推送 **`clice/inactiveRegions`** 通知,携带该文件在当前编译上下文下的非活跃预处理区域(未选中的 `#if` 分支体)。编辑器将它们变暗渲染;切换上下文触发重编译后区域随之翻转,这也是上下文切换最直观的视觉反馈。 -**`clice/currentContext`** 返回当前生效的用户选择。如果用户没有主动切换过(使用的是自动选择的默认上下文),返回空。 +## 自包含的自动判断 -**`clice/switchContext`** 切换到用户选择的新上下文。 +绝大多数头文件是自包含的,为它们合成前缀纯属浪费——需要计算 include 链、读取链上所有文件、引入大量前置依赖。因此 clice 对不在 CDB 中的头文件采用**先试后退**的策略: + +1. **试编译**:默认当作自包含处理,借用宿主的编译命令直接编译(不合成前缀)。 +2. **诊断打分**:如果试编译的诊断命中一组严格筛选的"缺失上下文"特征——未知类型名、未声明标识符、未闭合的条件编译指令——则判定为非自包含。判据刻意收窄:误判为非自包含只是多合成一次前缀,而把用户正在输入产生的普通语法错误误判进去会导致无意义的重编译。 +3. **回退重编**:判定非自包含后,合成前缀重新编译。试编译产生的错误诊断不会发布给编辑器,用户只会看到最终结果。 + +`.def` 和 `.inc` 扩展名的文件(X macro 惯例)跳过试编译,直接按非自包含处理。只有"非自包含"的判定会持久化到工作区缓存——它是昂贵的那一边,重启后不必重复试编译;"自包含"只是会话内的印象,重新验证的成本为零(试编译就是常规编译本身)。判定的重评时机经过刻意区分:**依赖变化**(保存了链上文件、头文件自己包含的文件变化、磁盘上的外部修改)触发重评,**用户打字**不触发——否则输入到一半的未知类型名会不断引发无意义的前缀合成。头文件自身被保存后,持久化的判定也会被重置。 ## 实践中的头文件上下文 @@ -104,7 +120,7 @@ const char* error_message(int code) { > 这里讨论的前缀合成只针对用户打开的头文件。磁盘上未被打开的头文件不需要单独处理——它们在各个源文件编译时会被正常包含和处理。索引系统在索引每个源文件时会顺带收集其中头文件的符号信息,再由 MergedIndex 将同一个头文件在不同源文件中产生的索引数据合并起来(合并机制见 [索引设计](symbol-index.md))。 -clice 采用的方案是**前缀合成 + `-include` 注入**:根据头文件上下文中的宿主源文件和 include 位置,沿 include 链提取目标头文件之前的所有内容,合成为前缀代码并写入磁盘上的前缀文件,然后通过 Clang 的 `-include` 标志将该文件注入到编译命令中。这个方案的核心优势是与 PCH 优化的天然配合——前缀文件的内容就是目标头文件的 preamble,可以编译为 PCH 缓存起来,用户后续编辑头文件主体时不必每次都重新处理前缀中大量的头文件包含。方案选择的详细论证见下方 FAQ 一节。PCH 的构建、缓存和失效机制见 [增量编译设计](incremental-parse.md)。 +clice 采用的方案是**前缀合成 + `-include` 注入**:根据头文件上下文中的宿主源文件和 include 位置,沿 include 链提取目标头文件之前的所有内容,合成为前缀代码并写入磁盘上的前缀文件,然后通过 Clang 的 `-include` 标志将该文件注入到编译命令中。这个方案的核心优势是与 PCH 优化的天然配合——`-include` 的文件经由 Clang 的 predefines 缓冲区在主文件之前被处理,构建 preamble PCH 时前缀内容与头文件自身的 preamble 区一起被烘进**同一个** PCH;即使头文件自身没有任何 include 指令(X macro 风格的 `.def` 文件),也会为前缀单独构建 PCH。消费 PCH 时 Clang 会校验并跳过两侧一致的 `-include`,前缀不会被处理两次。PCH 按内容键(preamble 文本 + 规范化参数)缓存,前缀相同的文件自动共享一份。用户后续编辑头文件主体时不必每次都重新处理前缀中大量的头文件包含。方案选择的详细论证见下方 FAQ 一节。PCH 的构建、缓存和失效机制见 [增量编译设计](incremental-parse.md)。 合成过程分为四个阶段。下面用一个具体的例子来说明。假设项目中有如下文件: @@ -153,7 +169,11 @@ clang -std=c++17 -Iinclude -include cache/header_context/a1b2c3d4.h math.h Clang 会先处理 `-include` 指定的前缀文件,再编译 `math.h`。效果上 `math.h` 就像在 `main.cpp` 的 `#include "math.h"` 位置被编译一样,拥有正确的预处理器状态。 -合成结果(宿主 ID、前缀文件路径、内容哈希)缓存在 Session 中。只要宿主源文件没变,后续编译直接复用,不用重新合成。 +合成有几个关键的工程细节。include 指令用宿主命令的真实搜索路径解析后按**绝对路径**匹配,同名不同目录的头文件不会混淆;前缀文件位于缓存目录,其中相对路径的引号包含会被改写为解析后的绝对路径,否则会以错误的目录为基准查找。匹配优先选择不在 `#if` 块内的 include(未选中分支里的包含不应遮蔽真实的那个);当截断点落在 `#if` 块内部时(最常见的是中间头文件的 include guard),按深度补齐 `#endif` 保证前缀良构——guard 条件仍由编译器求值,语义不变。 + +前缀之外还有**后缀**:include 位置之后的内容(沿链镜像拼接——直接包含者的剩余部分在前,宿主的在后)被合成为后缀文件,编译时在头文件缓冲区末尾追加一行 `#include` 注入。这使得嵌在 enum 或函数体内部的 X macro 片段能看到包围它的闭合括号——token 流跨越前缀、主文件、后缀连续展开,语法完整。截断点在 `#if` 块内时,前缀补 `#endif` 闭合、后缀以对应数量的 `#if 1` 重开,两侧各自平衡。链上对头文件自身的其他包含(同一文件的另一个 occurrence)会改写为指向它的磁盘快照——头文件自身的路径在编译时被重映射为带后缀追加行的缓冲区,原样保留会造成无限递归。追加的那一行位于编辑器可见内容之外,后缀文件里的诊断也不会归属到头文件本身。 + +合成结果(宿主、前缀文件路径、后缀文件路径、内容哈希、include 链及其内容快照)缓存在 Session 中。链上文件的内容被"拓印"进了前缀,编译器不会打开它们本身,因此常规的依赖追踪对它们失明——失效由链快照(mtime + 内容哈希两层检测)负责:链上任何文件变化,前缀都会重新合成。保存链上文件时会强制按内容重新校验,即使 mtime 未变。 ## 索引中的多重上下文 @@ -198,39 +218,8 @@ Clang 会先处理 `-include` 指定的前缀文件,再编译 `math.h`。效 ## 已知局限 -- **自包含判断尚未自动化**。实际项目中绝大多数头文件都是自包含的,只有少数(X macro 列表、内部实现片段等)不是。前缀代码的合成开销不小——需要查找宿主、计算 include 链、合成前缀代码并引入大量前置依赖——因此不应该对所有打开的头文件都执行。理想的策略是:默认先当作自包含处理;如果编译时出现大量特定类型的错误(如符号未定义),再自动 fallback 到非自包含模式,合成前缀代码重新编译。也可以利用已有的索引信息(如该头文件在其他源文件的编译中是否报错)来辅助判断。目前这套自动判断机制尚未实现。 - -- **源文件的上下文切换尚未完整实现**。`clice/queryContext` 已经能列出源文件的多条 CDB 条目,但 `clice/switchContext` 目前只支持头文件的宿主切换,还不支持在同一个源文件的多条编译命令之间切换。这是已知的待改进项。 - -- **宿主选择的确定性**。多个宿主可用时,当前选择 BFS 中第一个找到的、在 CDB 中有条目的源文件。更好的策略是优先选择与目标头文件"关系最近"的宿主——同目录下的源文件、名称相近的源文件等——但这需要更精细的启发式规则。 - -- **缺少后缀处理**。当前的前缀合成方案只注入了 include 位置之前的内容,没有处理 include 位置之后的内容。对于大多数头文件这不是问题,但存在两类边界情况。 - - 一是 X macro 这类被嵌入在函数体内部的非自包含头文件,include 位置后面的闭合括号会缺失: - - ```cpp - // generate.cpp - void register_all() { - #define X(name, code, msg) register_error(code, msg); - #include "errors.def" // 前缀在此截断,下面的 } 丢失 - #undef X - } - ``` - - 二是 `#include` 出现在 `#if`/`#ifndef` 块内部时,截断会产生未闭合的条件编译指令: - - ```cpp - // wrapper.h - #ifndef USE_LEGACY - #include - #include "target.h" // 前缀在此截断,#ifndef 没有对应的 #endif - #else - #include - #endif - ``` - - 这些不完整的语法结构可能导致 Clang 报告错误,但由于错误发生在前缀文件中而非目标头文件内,通常会被过滤掉。未来可能需要引入后缀注入来处理这类场景。 +- **occurrence 编号只覆盖直接包含者**。设计上,头文件上下文由宿主源文件加 include 树中的位置编号唯一确定。当前实现的 occurrence 只区分**直接包含者**中对目标头文件的多次包含(这覆盖了 X macro 的典型用法),而不是对宿主整个 include 树的展平编号——同一头文件经由不同中间路径被同一宿主传递包含的场景,目前只呈现最短链对应的那一个上下文。 -- **前缀合成中的递归包含**。include 图中可能存在循环(通过 include guard 或 `#pragma once` 打断)。依赖扫描阶段已经正确处理了循环检测,但前缀合成路径中对这种场景的处理尚未充分验证。 +- **最短链未必对应真实的首次包含**。从宿主到目标头文件的 include 链取的是 include 图上的最短路径。真实编译中头文件首次被包含时走的可能是另一条更长的路径,两者前面积累的预处理器状态可能不同。实践中这种差异很少造成可观察的影响,但严格来说合成出的状态可能不与任何一次真实编译完全一致。 -- **PCH 缓存尚未实现跨文件共享**。目前 PCH 缓存以文件的 `path_id` 为键,每个文件独立维护自己的 PCH。如果两个不同文件的 preamble 内容恰好相同,它们会各自独立构建 PCH,而不是共享同一份。将缓存键改为 preamble 内容哈希以实现跨文件的 PCH 共享,是计划中的优化。 +- **同一 TU 内跨配置的条件包含**。依赖扫描按编译配置分别记录 include 边,但宿主查找使用的是所有配置的并集。极端情况下,一个只在配置 A 中成立的 include 边可能把配置 B 的命令借给头文件。 diff --git a/editors/nvim/tests/e2e.lua b/editors/nvim/tests/e2e.lua index 5616a0cc07..86cd3c57d9 100644 --- a/editors/nvim/tests/e2e.lua +++ b/editors/nvim/tests/e2e.lua @@ -45,6 +45,13 @@ local scenarios = { index_symbol = 'magic_number', definition_file = 'defs.cppm', }, + header_context = { + file = 'utils.h', + symbol = 'distance(p', + index_symbol = 'calc', + -- no definition_file: cross-file definition into headers is a + -- known index gap. + }, } local scenario = scenarios[vim.fs.basename(fixture_dir)] @@ -149,21 +156,23 @@ if not hover or not hover.contents then fail 'hover returned no contents' end -step 'definition' -local definition = request('textDocument/definition', { - textDocument = text_document, - position = position, -}) -if definition and definition.uri then - definition = { definition } -end -if not definition or #definition == 0 then - fail 'definition returned no locations' -end -local target = definition[1].uri or definition[1].targetUri -local target_file = vim.fs.basename(vim.uri_to_fname(target)) -if target_file ~= scenario.definition_file then - fail('definition landed in ' .. target_file .. ', expected ' .. scenario.definition_file) +if scenario.definition_file then + step 'definition' + local definition = request('textDocument/definition', { + textDocument = text_document, + position = position, + }) + if definition and definition.uri then + definition = { definition } + end + if not definition or #definition == 0 then + fail 'definition returned no locations' + end + local target = definition[1].uri or definition[1].targetUri + local target_file = vim.fs.basename(vim.uri_to_fname(target)) + if target_file ~= scenario.definition_file then + fail('definition landed in ' .. target_file .. ', expected ' .. scenario.definition_file) + end end step 'completion' diff --git a/editors/vscode/.vscode-test.mjs b/editors/vscode/.vscode-test.mjs index 10a75cf409..5a5e000ae9 100644 --- a/editors/vscode/.vscode-test.mjs +++ b/editors/vscode/.vscode-test.mjs @@ -7,7 +7,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../ // Each fixture under tests/data becomes one test run with that workspace. // Run tests/prepare.py first to generate compile databases. // Keep in sync with the editor tasks in pixi.toml. -const fixtures = ["hello_world", "modules/hover_on_imported_symbol"]; +const fixtures = ["hello_world", "modules/hover_on_imported_symbol", "header_context"]; export default defineConfig( fixtures.map((fixture) => ({ diff --git a/editors/vscode/package.json b/editors/vscode/package.json index d54b432397..1a4fb824cd 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -26,7 +26,8 @@ "activationEvents": [ "onLanguage:c", "onLanguage:cpp", - "onLanguage:cuda-cpp" + "onLanguage:cuda-cpp", + "onStartupFinished" ], "contributes": { "configuration": { @@ -66,6 +67,31 @@ { "command": "clice.restart", "title": "Clice: Restart Language Server" + }, + { + "command": "clice.switchContext", + "title": "Clice: Switch Compilation Context" + }, + { + "command": "clice.showCurrentContext", + "title": "Clice: Show Current Compilation Context" + }, + { + "command": "clice.queryContexts", + "title": "Clice: Query Compilation Contexts" + }, + { + "command": "clice.refreshContexts", + "title": "Clice: Refresh Compilation Contexts", + "icon": "$(refresh)" + }, + { + "command": "clice.applyContext", + "title": "Clice: Apply Compilation Context" + }, + { + "command": "clice.loadMoreContexts", + "title": "Clice: Load More Compilation Contexts" } ], "semanticTokenTypes": [ @@ -139,7 +165,63 @@ "description": "Angle brackets `<>`", "superType": "operator" } - ] + ], + "views": { + "explorer": [ + { + "id": "clice.contexts", + "name": "Compilation Contexts", + "when": "editorLangId == c || editorLangId == cpp || editorLangId == cuda-cpp" + } + ] + }, + "menus": { + "commandPalette": [ + { + "command": "clice.applyContext", + "when": "false" + }, + { + "command": "clice.loadMoreContexts", + "when": "false" + }, + { + "command": "clice.refreshContexts", + "when": "false" + }, + { + "command": "clice.switchContext", + "when": "editorLangId == c || editorLangId == cpp || editorLangId == cuda-cpp" + }, + { + "command": "clice.showCurrentContext", + "when": "editorLangId == c || editorLangId == cpp || editorLangId == cuda-cpp" + }, + { + "command": "clice.queryContexts", + "when": "editorLangId == c || editorLangId == cpp || editorLangId == cuda-cpp" + } + ], + "editor/context": [ + { + "command": "clice.switchContext", + "when": "editorLangId == c || editorLangId == cpp || editorLangId == cuda-cpp", + "group": "clice@1" + }, + { + "command": "clice.showCurrentContext", + "when": "editorLangId == c || editorLangId == cpp || editorLangId == cuda-cpp", + "group": "clice@2" + } + ], + "view/title": [ + { + "command": "clice.refreshContexts", + "when": "view == clice.contexts", + "group": "navigation" + } + ] + } }, "main": "./dist/extension.js", "scripts": { diff --git a/editors/vscode/pnpm-workspace.yaml b/editors/vscode/pnpm-workspace.yaml index f7f501c670..aae0d087a4 100644 --- a/editors/vscode/pnpm-workspace.yaml +++ b/editors/vscode/pnpm-workspace.yaml @@ -1,3 +1,5 @@ -onlyBuiltDependencies: - - "@vscode/vsce-sign" - - keytar +# vsce-sign (marketplace signing) and keytar (credential store) are vsce +# transitive deps; their build scripts are not needed for local dev or CI. +allowBuilds: + "@vscode/vsce-sign": false + keytar: false diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 302608dd9e..f9e57bfd9e 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -9,6 +9,8 @@ import { } from "vscode-languageclient/node"; import { getSetting } from "./setting"; import { ensureServerBinary } from "./download"; +import { registerCompilationContext } from "./feature/context"; +import { registerInactiveRegions } from "./feature/inactive"; let client: LanguageClient; @@ -71,7 +73,11 @@ export async function activate(context: ExtensionContext) { } const clientOptions: LanguageClientOptions = { - documentSelector: [{ scheme: "file", language: "cpp" }], + documentSelector: [ + { scheme: "file", language: "cpp" }, + { scheme: "file", language: "c" }, + { scheme: "file", language: "cuda-cpp" }, + ], outputChannel: channel, traceOutputChannel: verboseChannel, synchronize: { @@ -84,6 +90,12 @@ export async function activate(context: ExtensionContext) { await registerCommands(client, context); await client.start(); + + registerCompilationContext(client, context); + registerInactiveRegions(client, context); + + // Exposed for E2E tests to exercise custom requests directly. + return { client }; } export function deactivate(): Thenable | undefined { diff --git a/editors/vscode/src/feature/context.ts b/editors/vscode/src/feature/context.ts new file mode 100644 index 0000000000..4566d85bbf --- /dev/null +++ b/editors/vscode/src/feature/context.ts @@ -0,0 +1,348 @@ +import * as vscode from "vscode"; +import { LanguageClient } from "vscode-languageclient/node"; + +export type ContextItem = { + label: string; + description: string; + uri: string; + occurrence?: number; + commandHash?: string; +}; + +type QueryContextResult = { contexts: ContextItem[]; total: number; epoch: number }; +type CurrentContextResult = { context: ContextItem | null }; +type SwitchContextResult = { success: boolean; stale?: boolean }; + +function isCppEditor(editor: vscode.TextEditor | undefined): editor is vscode.TextEditor { + const language = editor?.document.languageId; + return language === "c" || language === "cpp" || language === "cuda-cpp"; +} + +function sameContext(a: ContextItem, b: ContextItem | null | undefined): boolean { + if (!b) { + return false; + } + return ( + a.uri === b.uri && + (a.occurrence ?? 0) === (b.occurrence ?? 0) && + (a.commandHash ?? "") === (b.commandHash ?? "") + ); +} + +class ContextTreeItem extends vscode.TreeItem { + constructor( + readonly context: ContextItem | undefined, + readonly loadMore: boolean, + active: boolean, + epoch = 0, + ) { + super( + loadMore ? "Load more…" : (context?.label ?? ""), + vscode.TreeItemCollapsibleState.None, + ); + if (loadMore) { + this.iconPath = new vscode.ThemeIcon("ellipsis"); + this.command = { command: "clice.loadMoreContexts", title: "Load more" }; + return; + } + this.description = active ? `${context!.description} (active)` : context!.description; + this.tooltip = context!.description; + this.iconPath = new vscode.ThemeIcon(active ? "pass-filled" : "circle-large-outline"); + this.contextValue = "clice-context"; + this.command = { + command: "clice.applyContext", + title: "Switch to this context", + arguments: [context, epoch], + }; + } +} + +class ContextTreeProvider implements vscode.TreeDataProvider { + private emitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.emitter.event; + + private loaded: ContextItem[] = []; + private total = 0; + private current: ContextItem | null = null; + private uri: string | undefined; + epoch = 0; + + constructor(private client: LanguageClient) {} + + getTreeItem(element: ContextTreeItem) { + return element; + } + + async getChildren(element?: ContextTreeItem): Promise { + if (element) { + return []; + } + if (!this.uri) { + return []; + } + const items = this.loaded.map( + (context) => + new ContextTreeItem(context, false, sameContext(context, this.current), this.epoch), + ); + if (this.loaded.length < this.total) { + items.push(new ContextTreeItem(undefined, true, false)); + } + return items; + } + + async refresh(editor: vscode.TextEditor | undefined) { + if (!isCppEditor(editor)) { + this.uri = undefined; + this.loaded = []; + this.total = 0; + this.emitter.fire(); + return; + } + const uri = editor.document.uri.toString(); + this.uri = uri; + this.loaded = []; + this.total = 0; + try { + const [query, current] = await Promise.all([ + this.client.sendRequest("clice/queryContext", { uri }), + this.client.sendRequest("clice/currentContext", { uri }), + ]); + // A refresh for a previously active editor can finish after a + // newer one started; its results belong to the wrong document. + if (this.uri !== uri) { + return; + } + this.loaded = query?.contexts ?? []; + this.total = query?.total ?? this.loaded.length; + this.epoch = query?.epoch ?? 0; + this.current = current?.context ?? null; + } catch { + // Server not ready; leave the view empty. + } + this.emitter.fire(); + } + + async loadMore() { + if (!this.uri || this.loaded.length >= this.total) { + return; + } + const uri = this.uri; + try { + const query = await this.client.sendRequest("clice/queryContext", { + uri, + offset: this.loaded.length, + }); + if (this.uri !== uri) { + return; + } + this.loaded.push(...(query?.contexts ?? [])); + this.total = query?.total ?? this.loaded.length; + } catch { + // Keep what we have. + } + this.emitter.fire(); + } + + activeUri() { + return this.uri; + } +} + +export function registerCompilationContext(client: LanguageClient, ext: vscode.ExtensionContext) { + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + status.command = "clice.switchContext"; + status.tooltip = "clice: active compilation context (click to switch)"; + + const tree = new ContextTreeProvider(client); + + async function refresh(editor: vscode.TextEditor | undefined) { + void tree.refresh(editor); + if (!isCppEditor(editor)) { + status.hide(); + return; + } + try { + const result = await client.sendRequest("clice/currentContext", { + uri: editor.document.uri.toString(), + }); + const label = result?.context?.label ?? "auto"; + status.text = `$(list-tree) ${label}`; + status.show(); + } catch { + status.hide(); + } + } + + async function applyContext(picked: ContextItem, epoch?: number, targetUri?: string) { + // The QuickPick flow pins the document it queried for; switching + // editors while the pick is open must not retarget the request. + const uri = + targetUri ?? + tree.activeUri() ?? + vscode.window.activeTextEditor?.document.uri.toString(); + if (!uri) { + return; + } + const params: Record = { uri, contextUri: picked.uri }; + if (picked.occurrence !== undefined) { + params.occurrence = picked.occurrence; + } + if (picked.commandHash !== undefined) { + params.commandHash = picked.commandHash; + } + if (epoch !== undefined && epoch !== 0) { + params.epoch = epoch; + } + const switched = await client.sendRequest( + "clice/switchContext", + params, + ); + if (switched?.stale) { + vscode.window.showInformationMessage( + "clice: the workspace changed since this listing — refreshed, pick again", + ); + } else if (!switched?.success) { + vscode.window.showWarningMessage("clice: failed to switch compilation context"); + } else { + // The switch only marks the session dirty (pull-based server); + // fire a cheap feature request so diagnostics and inactive + // regions refresh without waiting for user interaction. + void vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + vscode.Uri.parse(uri), + ); + } + await refresh(vscode.window.activeTextEditor); + } + + async function select() { + const editor = vscode.window.activeTextEditor; + if (!isCppEditor(editor)) { + return; + } + const uri = editor.document.uri.toString(); + + const loaded: ContextItem[] = []; + let total = Number.POSITIVE_INFINITY; + let epoch = 0; + + while (true) { + if (loaded.length < total) { + const result = await client.sendRequest("clice/queryContext", { + uri, + offset: loaded.length, + }); + loaded.push(...(result?.contexts ?? [])); + total = result?.total ?? loaded.length; + epoch = result?.epoch ?? 0; + if (loaded.length === 0) { + vscode.window.showInformationMessage( + "clice: no compilation contexts available for this file", + ); + return; + } + } + + type ContextPick = vscode.QuickPickItem & { + context?: ContextItem; + loadMore?: boolean; + }; + const items: ContextPick[] = loaded.map((context) => ({ + label: context.label, + description: context.description, + context, + })); + if (loaded.length < total) { + items.push({ + label: `$(ellipsis) Load more (${loaded.length}/${total})`, + loadMore: true, + }); + } + + const chosen = await vscode.window.showQuickPick(items, { + title: "Switch Compilation Context", + placeHolder: "Compilation context to use for this file", + }); + if (!chosen) { + return; + } + if (chosen.loadMore) { + continue; + } + await applyContext(chosen.context!, epoch, uri); + return; + } + } + + // X-macro style fragments (.def/.inc/.inl/...) open as plain text and + // never reach the language server. If clice knows the file is included + // by some C++ TU (it has compilation contexts), flip its language so + // the whole toolchain attaches. + async function detectCxxFragment(document: vscode.TextDocument) { + if (document.languageId !== "plaintext") { + return; + } + if (!/\.(def|inc|inl|tpp|ipp)$/.test(document.uri.fsPath)) { + return; + } + try { + const query = await client.sendRequest("clice/queryContext", { + uri: document.uri.toString(), + }); + if ((query?.total ?? 0) > 0) { + await vscode.languages.setTextDocumentLanguage(document, "cpp"); + } + } catch { + // Server not ready — leave the document as-is. + } + } + + async function showCurrent() { + const editor = vscode.window.activeTextEditor; + if (!isCppEditor(editor)) { + return; + } + const result = await client.sendRequest("clice/currentContext", { + uri: editor.document.uri.toString(), + }); + const context = result?.context; + if (!context) { + vscode.window.showInformationMessage( + "clice: automatic compilation context (no explicit selection)", + ); + return; + } + const occurrence = + context.occurrence !== undefined && context.occurrence > 0 + ? ` (occurrence #${context.occurrence + 1})` + : ""; + vscode.window.showInformationMessage( + `clice: ${context.label}${occurrence} — ${context.description}`, + ); + } + + async function query() { + await refresh(vscode.window.activeTextEditor); + await vscode.commands.executeCommand("clice.contexts.focus"); + } + + ext.subscriptions.push( + status, + vscode.workspace.onDidOpenTextDocument((document) => void detectCxxFragment(document)), + vscode.window.registerTreeDataProvider("clice.contexts", tree), + vscode.commands.registerCommand("clice.switchContext", select), + vscode.commands.registerCommand("clice.showCurrentContext", showCurrent), + vscode.commands.registerCommand("clice.queryContexts", query), + vscode.commands.registerCommand("clice.applyContext", applyContext), + vscode.commands.registerCommand("clice.loadMoreContexts", () => tree.loadMore()), + vscode.commands.registerCommand("clice.refreshContexts", () => + refresh(vscode.window.activeTextEditor), + ), + vscode.window.onDidChangeActiveTextEditor((editor) => void refresh(editor)), + ); + void refresh(vscode.window.activeTextEditor); + // Documents opened before activation never fire onDidOpenTextDocument. + for (const document of vscode.workspace.textDocuments) { + void detectCxxFragment(document); + } +} diff --git a/editors/vscode/src/feature/header.ts b/editors/vscode/src/feature/header.ts deleted file mode 100644 index 06dffb21a6..0000000000 --- a/editors/vscode/src/feature/header.ts +++ /dev/null @@ -1,72 +0,0 @@ -import * as vscode from "vscode"; -import { DocumentUri } from "vscode-languageclient/node"; - -let provider: HeaderContextProvider | undefined = undefined; - -export type HeaderContext = { - file: string; - index: number; - include: number; -}; - -export type HeaderContextSwitchParams = { - header: DocumentUri; - context: HeaderContext; -}; - -export type IncludeLocation = { - line: number; - filename: string; -}; - -export class TreeItem extends vscode.TreeItem { - children: Array = []; - context: HeaderContext | undefined = undefined; -} - -export class HeaderContextProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = - this._onDidChangeTreeData.event; - - header: string = ""; - items: Array = []; - - update(contexts: Array>) { - /// Create groups - this.items = contexts.map((contexts) => { - let item = new TreeItem("", vscode.TreeItemCollapsibleState.Expanded); - item.children = contexts.map((context) => { - const uri = vscode.Uri.file(context.file); - let item = new TreeItem(uri, vscode.TreeItemCollapsibleState.None); - item.context = context; - item.iconPath = vscode.ThemeIcon.File; - item.contextValue = "header-context"; - return item; - }); - return item; - }); - - this.refresh(); - } - - refresh(): void { - this._onDidChangeTreeData.fire(); - } - - getTreeItem(element: TreeItem) { - return element; - } - - getChildren(element?: TreeItem) { - return element ? element.children : this.items; - } -} - -export function registerHeaderContextView() { - provider = new HeaderContextProvider(); - let treeView = vscode.window.createTreeView("header-contexts", { - treeDataProvider: provider, - }); -} diff --git a/editors/vscode/src/feature/inactive.ts b/editors/vscode/src/feature/inactive.ts new file mode 100644 index 0000000000..49c9a1c411 --- /dev/null +++ b/editors/vscode/src/feature/inactive.ts @@ -0,0 +1,42 @@ +import * as vscode from "vscode"; +import { LanguageClient } from "vscode-languageclient/node"; + +type LspRange = { + start: { line: number; character: number }; + end: { line: number; character: number }; +}; + +type InactiveRegionsParams = { uri: string; regions: LspRange[] }; + +/// Renders clice/inactiveRegions: preprocessor-inactive regions pushed by +/// the server after each compile, dimmed like unreachable code. Switching +/// the compilation context recompiles and re-pushes, so the dimming flips +/// with the selected preprocessor state. +export function registerInactiveRegions(client: LanguageClient, ext: vscode.ExtensionContext) { + const decoration = vscode.window.createTextEditorDecorationType({ + opacity: "0.45", + }); + const byUri = new Map(); + + function apply(editor: vscode.TextEditor) { + const ranges = byUri.get(editor.document.uri.toString()) ?? []; + editor.setDecorations(decoration, ranges); + } + + client.onNotification("clice/inactiveRegions", (params: InactiveRegionsParams) => { + const ranges = params.regions.map( + (r) => new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character), + ); + byUri.set(params.uri, ranges); + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === params.uri) { + apply(editor); + } + } + }); + + ext.subscriptions.push( + decoration, + vscode.window.onDidChangeVisibleTextEditors((editors) => editors.forEach(apply)), + ); +} diff --git a/editors/vscode/src/test/e2e.test.ts b/editors/vscode/src/test/e2e.test.ts index 5e1448e27a..84273de8aa 100644 --- a/editors/vscode/src/test/e2e.test.ts +++ b/editors/vscode/src/test/e2e.test.ts @@ -11,7 +11,9 @@ interface Scenario { file: string; symbol: string; indexSymbol: string; - definitionFile: string; + // Absent when cross-file definition is not expected to work for the + // scenario (definition into headers is a known index gap). + definitionFile?: string; } const scenarios: Record = { @@ -27,6 +29,11 @@ const scenarios: Record = { indexSymbol: "magic_number", definitionFile: "defs.cppm", }, + header_context: { + file: "utils.h", + symbol: "distance(p", + indexSymbol: "calc", + }, }; suite("clice E2E", function () { @@ -118,6 +125,9 @@ suite("clice E2E", function () { test("definition", async function () { this.timeout(60 * 1000); + if (!scenario.definitionFile) { + this.skip(); + } assert.ok(document, "main file was not opened (earlier test failed)"); const locations = await vscode.commands.executeCommand< @@ -130,6 +140,37 @@ suite("clice E2E", function () { assert.strictEqual(path.basename(target.fsPath), scenario.definitionFile); }); + test("compilation context requests", async function () { + this.timeout(60 * 1000); + const folder = vscode.workspace.workspaceFolders![0]; + if (path.basename(folder.uri.fsPath) !== "header_context") { + this.skip(); + } + assert.ok(document, "main file was not opened (earlier test failed)"); + + const extension = vscode.extensions.getExtension("ykiko.clice-vscode"); + assert.ok(extension?.isActive, "extension not active"); + const client = extension.exports.client; + const uri = document.uri.toString(); + + const query = await client.sendRequest("clice/queryContext", { uri }); + assert.ok(query.total >= 1, `expected at least one context, got ${query.total}`); + const host = query.contexts.find((c: { uri: string }) => c.uri.includes("main.cpp")); + assert.ok(host, "main.cpp should be offered as a context"); + + const switched = await client.sendRequest("clice/switchContext", { + uri, + contextUri: host.uri, + }); + assert.ok(switched.success, "switchContext should succeed"); + + const current = await client.sendRequest("clice/currentContext", { uri }); + assert.ok( + current.context && current.context.uri.includes("main.cpp"), + "currentContext should report the switched host", + ); + }); + test("completion", async function () { this.timeout(60 * 1000); assert.ok(document, "main file was not opened (earlier test failed)"); diff --git a/pixi.toml b/pixi.toml index 999c8ffc36..69bbd795f3 100644 --- a/pixi.toml +++ b/pixi.toml @@ -217,14 +217,15 @@ python = ">=3.13" # Fixture list kept in sync with editors/vscode/.vscode-test.mjs. [feature.editor.tasks.editor-prepare] -cmd = "python tests/prepare.py hello_world modules/hover_on_imported_symbol" +cmd = "python tests/prepare.py hello_world modules/hover_on_imported_symbol header_context" # Requires nvim (stable) on PATH. [feature.editor.tasks.nvim-e2e] args = [{ arg = "type", default = "RelWithDebInfo" }] cmd = """ nvim -l editors/nvim/tests/e2e.lua build/{{ type }}/bin/clice tests/data/hello_world && \ -nvim -l editors/nvim/tests/e2e.lua build/{{ type }}/bin/clice tests/data/modules/hover_on_imported_symbol +nvim -l editors/nvim/tests/e2e.lua build/{{ type }}/bin/clice tests/data/modules/hover_on_imported_symbol && \ +nvim -l editors/nvim/tests/e2e.lua build/{{ type }}/bin/clice tests/data/header_context """ depends-on = ["editor-prepare"] diff --git a/src/command/argument_parser.cpp b/src/command/argument_parser.cpp index dbdcca157f..6a8217126f 100644 --- a/src/command/argument_parser.cpp +++ b/src/command/argument_parser.cpp @@ -1,5 +1,6 @@ #include "command/argument_parser.h" +#include #include #include #include @@ -10,6 +11,7 @@ #include "llvm/Support/FileSystem.h" #include "llvm/Support/Path.h" #include "llvm/Support/raw_ostream.h" +#include "llvm/Support/xxhash.h" #include "clang/Driver/Driver.h" #include "clang/Driver/Types.h" @@ -330,6 +332,16 @@ std::string canonicalize(llvm::ArrayRef args, ArgsProfile profile) return buf; } +std::string canonical_command_hash(llvm::ArrayRef args, llvm::StringRef directory) { + auto canonical = canonicalize(args, ArgsProfile::Frontend); + // Identical argv can still mean different compiles when relative paths + // (-include config.h) resolve against different working directories. + canonical += '\0'; + canonical += directory; + auto hash = llvm::xxh3_64bits(llvm::StringRef(canonical)); + return std::format("{:016x}", hash); +} + std::string print_argv(llvm::ArrayRef args) { std::string buf; llvm::raw_string_ostream os(buf); diff --git a/src/command/argument_parser.h b/src/command/argument_parser.h index c9505275d9..3a1b4b29dc 100644 --- a/src/command/argument_parser.h +++ b/src/command/argument_parser.h @@ -104,6 +104,13 @@ enum class ArgsProfile : std::uint8_t { /// not. std::string canonicalize(llvm::ArrayRef args, ArgsProfile profile); +/// Stable identity of a compile configuration: hex xxh3 hash over the +/// frontend-relevant canonical form of the arguments. Entries that would +/// produce the same compilation result share one hash — used to dedupe +/// contexts in clice/queryContext and to address a CDB entry in +/// clice/switchContext. +std::string canonical_command_hash(llvm::ArrayRef args, llvm::StringRef directory); + /// Get the resource directory for clang builtin headers. Computed once /// from the current executable path using Driver::GetResourcesPath. llvm::StringRef resource_dir(); diff --git a/src/compile/directive.cpp b/src/compile/directive.cpp index bf99309647..42eac6ddd3 100644 --- a/src/compile/directive.cpp +++ b/src/compile/directive.cpp @@ -28,23 +28,27 @@ class DirectiveCollector : public clang::PPCallbacks { clang::PPCallbacks::ConditionValueKind value, clang::SourceRange condition_range) { Condition::ConditionValue cond_value = - value == clang::PPCallbacks::CVK_False ? Condition::None + value == clang::PPCallbacks::CVK_False ? Condition::False : value == clang::PPCallbacks::CVK_True ? Condition::True : value == clang::PPCallbacks::CVK_NotEvaluated ? Condition::Skipped : Condition::None; add_condition(loc, kind, cond_value, condition_range); } + /// `negated` flips the recorded truth for #ifndef/#elifndef: the + /// stored value is the BRANCH truth (was it taken), not whether the + /// macro is defined — an include guard's first pass is active. void add_condition(clang::SourceLocation loc, Condition::BranchKind kind, const clang::Token& name, - const clang::MacroDefinition& definition) { - if(auto def = definition.getMacroInfo()) { + const clang::MacroDefinition& definition, + bool negated = false) { + auto def = definition.getMacroInfo(); + if(def) { add_macro(def, MacroRef::Ref, name.getLocation()); - add_condition(loc, kind, Condition::True, name.getLocation()); - } else { - add_condition(loc, kind, Condition::False, name.getLocation()); } + bool taken = negated ? def == nullptr : def != nullptr; + add_condition(loc, kind, taken ? Condition::True : Condition::False, name.getLocation()); } void add_macro(const clang::MacroInfo* def, MacroRef::Kind kind, clang::SourceLocation loc) { @@ -227,14 +231,14 @@ class DirectiveCollector : public clang::PPCallbacks { void Ifndef(clang::SourceLocation loc, const clang::Token& name, const clang::MacroDefinition& definition) override { - add_condition(loc, Condition::Ifndef, name, definition); + add_condition(loc, Condition::Ifndef, name, definition, /*negated=*/true); } // Invoke when #elifndef is taken. void Elifndef(clang::SourceLocation loc, const clang::Token& name, const clang::MacroDefinition& definition) override { - add_condition(loc, Condition::Elifndef, name, definition); + add_condition(loc, Condition::Elifndef, name, definition, /*negated=*/true); } // Invoke when #elifndef is skipped. diff --git a/src/feature/inactive_regions.cpp b/src/feature/inactive_regions.cpp new file mode 100644 index 0000000000..1d795b8044 --- /dev/null +++ b/src/feature/inactive_regions.cpp @@ -0,0 +1,152 @@ +#include "feature/inactive_regions.h" + +#include "compile/directive.h" + +#include "llvm/ADT/SmallVector.h" + +namespace clice::feature { + +InactiveScan inactive_regions(CompilationUnitRef unit, + llvm::ArrayRef open_stack, + std::uint32_t resume_offset, + std::uint32_t end_offset) { + InactiveScan result; + + auto interested = unit.interested_file(); + auto content = unit.file_content(interested); + if(end_offset > content.size()) { + end_offset = static_cast(content.size()); + } + + // Offset just past the end of the line containing `offset`. + auto line_end = [&](std::uint32_t offset) -> std::uint32_t { + auto pos = content.find('\n', offset); + return pos == llvm::StringRef::npos ? static_cast(content.size()) + : static_cast(pos + 1); + }; + + // Offset of the start of the line containing `offset`. + auto line_begin = [&](std::uint32_t offset) -> std::uint32_t { + auto pos = content.rfind('\n', offset); + return pos == llvm::StringRef::npos ? 0 : static_cast(pos + 1); + }; + + auto local_offset = [&](clang::SourceLocation loc) -> std::optional { + auto [fid, offset] = unit.decompose_location(loc); + if(fid != interested) { + return std::nullopt; + } + return offset; + }; + + // Walk the branch directives with an explicit nesting stack, seeded + // from a preceding preamble scan: its pending inactive levels start + // at the resume offset. + struct Level { + std::optional inactive_begin; + + /// Whether some earlier branch of this level was taken — an #else + /// carries no condition value, so its inactivity is derived. + bool taken = false; + }; + + llvm::SmallVector stack; + for(auto encoded: open_stack) { + Level level; + if(encoded & 1) { + level.inactive_begin = resume_offset; + } + level.taken = (encoded & 2) != 0; + stack.push_back(level); + } + + auto is_inactive = [](const Condition& condition) { + return condition.value == Condition::ConditionValue::False || + condition.value == Condition::ConditionValue::Skipped; + }; + + auto close_pending = [&](Level& level, std::uint32_t end) { + if(!level.inactive_begin.has_value()) { + return; + } + auto begin = *level.inactive_begin; + if(begin < end) { + result.regions.push_back(begin); + result.regions.push_back(end); + } + level.inactive_begin.reset(); + }; + + auto directives_it = unit.directives().find(interested); + if(directives_it != unit.directives().end()) { + for(const auto& condition: directives_it->second.conditions) { + auto offset = local_offset(condition.loc); + if(!offset) { + continue; + } + + switch(condition.kind) { + case Condition::BranchKind::If: + case Condition::BranchKind::Ifdef: + case Condition::BranchKind::Ifndef: { + stack.push_back({}); + if(is_inactive(condition)) { + stack.back().inactive_begin = line_end(*offset); + } else { + stack.back().taken = true; + } + break; + } + case Condition::BranchKind::Elif: + case Condition::BranchKind::Elifdef: + case Condition::BranchKind::Elifndef: { + if(stack.empty()) { + break; + } + close_pending(stack.back(), line_begin(*offset)); + if(is_inactive(condition)) { + stack.back().inactive_begin = line_end(*offset); + } else { + stack.back().taken = true; + } + break; + } + case Condition::BranchKind::Else: { + if(stack.empty()) { + break; + } + close_pending(stack.back(), line_begin(*offset)); + // #else has no condition value: it is inactive exactly + // when an earlier branch of this level was taken. + if(stack.back().taken) { + stack.back().inactive_begin = line_end(*offset); + } + break; + } + case Condition::BranchKind::EndIf: { + if(stack.empty()) { + break; + } + close_pending(stack.back(), line_begin(*offset)); + stack.pop_back(); + break; + } + } + } + } + + // Levels still open at the content bound: close their regions there + // and report the stack so a follow-up scan can resume. + for(auto& level: stack) { + std::uint8_t encoded = level.inactive_begin.has_value() ? 1 : 0; + if(level.taken) { + encoded |= 2; + } + result.open_stack.push_back(encoded); + close_pending(level, end_offset); + } + + return result; +} + +} // namespace clice::feature diff --git a/src/feature/inactive_regions.h b/src/feature/inactive_regions.h new file mode 100644 index 0000000000..510cb18ac3 --- /dev/null +++ b/src/feature/inactive_regions.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include "compile/compilation_unit.h" + +#include "llvm/ADT/ArrayRef.h" + +namespace clice::feature { + +/// Result of scanning a unit for preprocessor-inactive regions. +struct InactiveScan { + /// Byte-offset ranges [begin0, end0, begin1, end1, ...] of inactive + /// branch bodies in the interested file; directive lines excluded. + std::vector regions; + + /// Conditional levels still open at the end of the scanned content, + /// outermost first. Bit 0: the level's current branch is inactive. + /// Bit 1: an earlier branch of the level was already taken (decides a + /// later #else). Preamble/PCH builds end mid-#if when the bound cuts + /// inside a block; the AST compile resumes from this stack. + std::vector open_stack; +}; + +/// Scan the interested file's condition directives. `open_stack` seeds the +/// nesting state (from a preceding preamble scan) and `resume_offset` is +/// where the scanned content starts — pending inactive levels from the +/// seed begin there. A scan that ends with open levels closes their +/// pending regions at `end_offset` (the content bound). +InactiveScan inactive_regions(CompilationUnitRef unit, + llvm::ArrayRef open_stack = {}, + std::uint32_t resume_offset = 0, + std::uint32_t end_offset = UINT32_MAX); + +} // namespace clice::feature diff --git a/src/semantic/context.h b/src/semantic/context.h deleted file mode 100644 index 26a8fb07b8..0000000000 --- a/src/semantic/context.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include - -namespace clice { - -struct HeaderContext { - /// Absolute file system path of the translation unit - /// that includes this header. - std::string tu; - - /// The canonical form of the compilation command used to - /// compile the translation unit. - std::string command; - - /// Zero-based ordinal of the include directive in the - /// translation unit that introduces this header. - std::uint32_t ordinal; -}; - -struct TUContext { - /// The canonical form of the compilation command for - /// this translation unit. - std::string command; -}; - -} // namespace clice diff --git a/src/server/compiler/compiler.cpp b/src/server/compiler/compiler.cpp index cbb09beb36..024779f11e 100644 --- a/src/server/compiler/compiler.cpp +++ b/src/server/compiler/compiler.cpp @@ -1,6 +1,7 @@ #include "server/compiler/compiler.h" #include +#include #include #include #include @@ -10,12 +11,14 @@ #include "command/search_config.h" #include "compile/diagnostic.h" #include "index/tu_index.h" +#include "server/protocol/extension.h" #include "server/protocol/worker.h" #include "support/anomaly.h" #include "support/filesystem.h" #include "support/logging.h" #include "support/timer.h" #include "syntax/include_resolver.h" +#include "syntax/preamble_synthesis.h" #include "syntax/scan.h" #include "kota/async/async.h" @@ -52,19 +55,69 @@ static std::string cache_key(std::initializer_list parts) { return std::format("{:016x}{:016x}", hash.high64, hash.low64); } +/// Diagnostic codes that strictly indicate a missing includer context (as +/// opposed to ordinary in-progress typing errors). Deliberately narrow: +/// a false positive costs a pointless prefix synthesis, a false negative +/// just leaves the header in trial mode. +static bool indicates_missing_context(llvm::ArrayRef diagnostics) { + constexpr static llvm::StringRef codes[] = { + "err_unknown_typename", + "err_undeclared_var_use", + "err_undeclared_var_use_suggest", + "err_pp_unterminated_conditional", + }; + for(auto& diag: diagnostics) { + if(diag.severity != protocol::DiagnosticSeverity::Error || !diag.code.has_value()) { + continue; + } + auto* code = std::get_if(&*diag.code); + if(code && llvm::is_contained(codes, *code)) { + return true; + } + } + return false; +} + +/// Pick the host CDB entry matching the session's pinned command hash +/// (multi-configuration hosts), defaulting to the first entry. +/// +/// Published hashes are computed against host-path rules, while `results` +/// may carry header-path rules — match by index through a host-rules +/// lookup of the same entry set instead of hashing `results` directly. +static CompileCommand& pick_host_command(Workspace& workspace, + llvm::StringRef host_path, + llvm::SmallVector& results, + const Session* session) { + if(session && session->active_context.has_value() && + !session->active_context->command_hash.empty()) { + std::vector host_append, host_remove; + workspace.config.match_rules(host_path, host_append, host_remove); + auto canonical = + workspace.cdb.lookup(host_path, {.remove = host_remove, .append = host_append}); + for(std::size_t i = 0; i < canonical.size() && i < results.size(); ++i) { + if(canonical_command_hash(canonical[i].to_string_argv(), + canonical[i].resolved.directory) == + session->active_context->command_hash) { + return results[i]; + } + } + } + return results.front(); +} + /// RAII completion of an in-flight PCH build registration: wakes waiters /// and clears the building marker on every exit path — crucially also when /// the coroutine is cancelled and its frame unwinds at a suspension point, /// which would otherwise leave waiters suspended on the event forever. struct BuildingGuard { Workspace& workspace; - std::uint32_t path_id; + llvm::StringRef key; std::shared_ptr completion; ~BuildingGuard() { - // Reset only our own registration: the entry may have been erased - // (didClose) and re-registered by a newer build in the meantime. - if(auto it = workspace.pch_cache.find(path_id); + // Reset only our own registration: the entry may have been + // re-registered by a newer build in the meantime. + if(auto it = workspace.pch_cache.find(key); it != workspace.pch_cache.end() && it->second.building == completion) { it->second.building.reset(); } @@ -301,10 +354,22 @@ CommandSource Compiler::fill_compile_args(llvm::StringRef path, std::vector rule_append, rule_remove; workspace.config.match_rules(path, rule_append, rule_remove); auto results = workspace.cdb.lookup(path, {.remove = rule_remove, .append = rule_append}); - workspace.toolchain.resolve_or_warn(results.front()); - auto& cmd = results.front(); - directory = cmd.resolved.directory.str(); - arguments = cmd.to_string_argv(); + auto* cmd = &results.front(); + // Multi-config projects: honor the user's chosen CDB entry, matched + // by canonical command hash so the choice survives CDB reordering. + if(session && session->active_command.has_value()) { + for(auto& candidate: results) { + if(canonical_command_hash(candidate.to_string_argv(), + candidate.resolved.directory) == + *session->active_command) { + cmd = &candidate; + break; + } + } + } + workspace.toolchain.resolve_or_warn(*cmd); + directory = cmd->resolved.directory.str(); + arguments = cmd->to_string_argv(); }; // 1. If the session has an active header context via switchContext, @@ -349,20 +414,68 @@ bool Compiler::fill_header_context_args(llvm::StringRef path, std::string& directory, std::vector& arguments, Session* session) { - // Use cached context if available; otherwise resolve. - // If an active context override exists, invalidate cache if it points to - // a different host so we re-resolve with the correct one. - const HeaderFileContext* ctx_ptr = nullptr; + // Opening one of our own synthesized files (prefix/suffix/snapshot): + // it is a fragment of the host TU it was synthesized for, so compile + // it with that host's command, treated as self-contained. It must not + // derive context from other synthesized state; without a recorded + // host (e.g. a stale artifact from a wiped cache), fall through to + // the default command. + if(workspace.is_synthesized_artifact(path)) { + auto it = workspace.synthesized_hosts.find(path); + if(it == workspace.synthesized_hosts.end()) { + return false; + } + auto host_path = workspace.path_pool.resolve(it->second); + if(!workspace.cdb.has_entry(host_path)) { + return false; + } + std::vector rule_append, rule_remove; + workspace.config.match_rules(path, rule_append, rule_remove); + auto host_results = + workspace.cdb.lookup(host_path, {.remove = rule_remove, .append = rule_append}); + workspace.toolchain.resolve_or_warn(host_results.front()); + CompileCommand artifact_cmd = host_results.front(); + artifact_cmd.source_file = workspace.path_pool.resolve(path_id).data(); + directory = artifact_cmd.resolved.directory.str(); + arguments = artifact_cmd.to_string_argv(); + return true; + } + + // Self-containment routing: an Unknown or SelfContained header borrows + // the host command without a prefix; NeedsContext synthesizes one. + // run_compile() flips Unknown to NeedsContext when the trial compile's + // diagnostics indicate missing includer state. An explicitly chosen + // occurrence — even #0 — only has meaning under includer-context + // semantics, so it forces synthesis regardless of the verdict. + bool synthesize = workspace.header_mode(path, path_id) == HeaderMode::NeedsContext || + (session && session->active_context.has_value() && + session->active_context->occurrence.has_value()); + + // Use cached context if it is still valid; otherwise resolve. The cache + // is dropped when an active context override points to a different host + // or include occurrence, when the routing mode changed, or when any + // chain file changed on disk (the synthesized preamble embeds their + // content, so it must be rebuilt). if(session && session->header_context.has_value()) { - if(session->active_context.has_value() && - session->header_context->host_path_id != *session->active_context) { + bool override_mismatch = + session->active_context.has_value() && + (session->header_context->host_path_id != session->active_context->host_path_id || + session->header_context->occurrence != + session->active_context->occurrence.value_or(0) || + session->header_context->host_command_hash != session->active_context->command_hash); + bool mode_mismatch = session->header_context->preamble_path.empty() == synthesize; + if(override_mismatch || mode_mismatch || + deps_changed(workspace.path_pool, session->header_context->deps)) { session->header_context.reset(); - } else { - ctx_ptr = &*session->header_context; } } - if(!ctx_ptr) { - auto resolved = resolve_header_context(path_id, session); + + std::optional local_ctx; + const HeaderContext* ctx_ptr = nullptr; + if(session && session->header_context.has_value()) { + ctx_ptr = &*session->header_context; + } else { + auto resolved = resolve_header_context(path_id, session, synthesize); if(!resolved) { LOG_WARN("No CDB entry and no header context for {}", path); return false; @@ -371,12 +484,9 @@ bool Compiler::fill_header_context_args(llvm::StringRef path, session->header_context = std::move(*resolved); ctx_ptr = &*session->header_context; } else { - // Background indexing path — no session to store on. - // Use a temporary (caller will use it immediately). - // Store in a local and return. - static thread_local std::optional tl_ctx; - tl_ctx = std::move(*resolved); - ctx_ptr = &*tl_ctx; + // Background indexing path — no session to cache on. + local_ctx = std::move(*resolved); + ctx_ptr = &*local_ctx; } } @@ -393,20 +503,22 @@ bool Compiler::fill_header_context_args(llvm::StringRef path, auto host_results = workspace.cdb.lookup(host_path, {.remove = rule_remove, .append = rule_append}); - workspace.toolchain.resolve_or_warn(host_results.front()); - - auto& host_cmd = host_results.front(); + auto& host_cmd = pick_host_command(workspace, host_path, host_results, session); + workspace.toolchain.resolve_or_warn(host_cmd); directory = host_cmd.resolved.directory.str(); - // Replace source_file and inject -include preamble into flags directly. + // Replace source_file; inject -include only when a prefix + // was synthesized (after "-cc1" for cc1, after the driver otherwise). CompileCommand header_cmd = host_cmd; header_cmd.source_file = workspace.path_pool.resolve(path_id).data(); - // Inject -include into flags: after "-cc1" for cc1, after driver otherwise. - std::size_t inject_pos = header_cmd.resolved.is_cc1 ? 2 : 1; - header_cmd.resolved.flags.insert(header_cmd.resolved.flags.begin() + inject_pos, - ctx_ptr->preamble_path.c_str()); - header_cmd.resolved.flags.insert(header_cmd.resolved.flags.begin() + inject_pos, "-include"); + if(!ctx_ptr->preamble_path.empty()) { + std::size_t inject_pos = header_cmd.resolved.is_cc1 ? 2 : 1; + header_cmd.resolved.flags.insert(header_cmd.resolved.flags.begin() + inject_pos, + ctx_ptr->preamble_path.c_str()); + header_cmd.resolved.flags.insert(header_cmd.resolved.flags.begin() + inject_pos, + "-include"); + } arguments = header_cmd.to_string_argv(); @@ -417,8 +529,9 @@ bool Compiler::fill_header_context_args(llvm::StringRef path, return true; } -std::optional Compiler::resolve_header_context(std::uint32_t header_path_id, - Session* session) { +std::optional Compiler::resolve_header_context(std::uint32_t header_path_id, + Session* session, + bool synthesize) { // Find source files that transitively include this header. auto hosts = workspace.dep_graph.find_host_sources(header_path_id); if(hosts.empty()) { @@ -426,25 +539,28 @@ std::optional Compiler::resolve_header_context(std::uint32_t return std::nullopt; } - // If there's an active context override, prefer that host. + // If there's an active context override, prefer that host (and its + // chosen include occurrence). std::uint32_t host_path_id = 0; + std::optional occurrence; std::vector chain; if(session && session->active_context.has_value()) { - auto preferred = *session->active_context; + auto preferred = session->active_context->host_path_id; auto preferred_path = workspace.path_pool.resolve(preferred); if(workspace.cdb.has_entry(preferred_path)) { auto c = workspace.dep_graph.find_include_chain(preferred, header_path_id); if(!c.empty()) { host_path_id = preferred; + occurrence = session->active_context->occurrence; chain = std::move(c); } } } - // Fall back to the first available host that has a real CDB entry — + // Fall back to the most relevant host that has a real CDB entry — // a host with a synthesized command would just be a fallback in disguise. if(chain.empty()) { - for(auto candidate: hosts) { + for(auto candidate: workspace.rank_hosts(header_path_id, hosts)) { auto candidate_path = workspace.path_pool.resolve(candidate); if(!workspace.cdb.has_entry(candidate_path)) continue; @@ -463,77 +579,131 @@ std::optional Compiler::resolve_header_context(std::uint32_t return std::nullopt; } - // Build preamble text: for each file in the chain except the last (target), - // append all content up to (but not including) the line that includes the - // next file in the chain. - std::string preamble; - for(std::size_t i = 0; i + 1 < chain.size(); ++i) { - auto cur_id = chain[i]; - auto next_id = chain[i + 1]; + // Self-contained route: borrow the host's command, no prefix needed. + // The chain is kept so a didSave along it still invalidates the session. + std::string host_command_hash; + if(session && session->active_context.has_value()) { + host_command_hash = session->active_context->command_hash; + } + + if(!synthesize) { + llvm::SmallVector chain_ids(chain.begin(), chain.end() - 1); + return HeaderContext{host_path_id, + "", + 0, + "", + occurrence.value_or(0), + std::move(host_command_hash), + std::move(chain_ids), + {}}; + } - auto cur_path = workspace.path_pool.resolve(cur_id); - auto next_path = workspace.path_pool.resolve(next_id); - auto next_filename = llvm::sys::path::filename(next_path); + // Include directives along the chain are resolved with the host's real + // search configuration, so same-named headers in different directories + // cannot be confused. + auto host_path = workspace.path_pool.resolve(host_path_id); + std::vector rule_append, rule_remove; + workspace.config.match_rules(host_path, rule_append, rule_remove); + auto host_results = + workspace.cdb.lookup(host_path, {.remove = rule_remove, .append = rule_append}); + if(host_results.empty()) { + return std::nullopt; + } + auto& resolve_cmd = pick_host_command(workspace, host_path, host_results, session); + workspace.toolchain.resolve_or_warn(resolve_cmd); + + auto argv = resolve_cmd.to_argv(); + auto search_config = extract_search_config(argv, resolve_cmd.resolved.directory); + DirListingCache dir_cache; + auto resolved_config = resolve_search_config(search_config, dir_cache); + + auto resolver = [&](llvm::StringRef filename, + bool is_angled, + bool is_include_next, + llvm::StringRef includer_dir) -> std::optional { + auto entries = resolve_dir(includer_dir, dir_cache); + auto result = resolve_include(filename, + is_angled, + entries, + includer_dir, + is_include_next, + 0, + resolved_config, + dir_cache); + if(!result) { + return std::nullopt; + } + // Normalize through the path pool: resolve_include builds native + // separators, but chain paths compared against it are pool-normalized. + return std::string(workspace.path_pool.resolve(workspace.path_pool.intern(result->path))); + }; - // Prefer in-memory document text over disk content. - std::string content; + // Read the chain files (all but the target) from disk. The synthesized + // preamble deliberately reflects disk state, never open-document buffers: + // open files must not be depended upon by other files. The staleness + // snapshot timestamp is taken before the reads so a concurrent write + // lands past build_at and triggers re-validation. + auto build_at = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + std::vector chain_contents; + llvm::SmallVector chain_entries; + chain_contents.reserve(chain.size() - 1); + chain_entries.reserve(chain.size() - 1); + for(std::size_t i = 0; i + 1 < chain.size(); ++i) { + auto cur_path = workspace.path_pool.resolve(chain[i]); auto buf = llvm::MemoryBuffer::getFile(cur_path); if(!buf) { LOG_WARN("resolve_header_context: cannot read {}", cur_path); return std::nullopt; } - content = (*buf)->getBuffer().str(); - - // Scan line by line for the #include that brings in next_filename. - llvm::StringRef content_ref(content); - std::size_t line_start = 0; - std::size_t include_line_start = std::string::npos; - while(line_start <= content_ref.size()) { - auto newline_pos = content_ref.find('\n', line_start); - auto line_end = - (newline_pos == llvm::StringRef::npos) ? content_ref.size() : newline_pos; - auto line = content_ref.slice(line_start, line_end).trim(); - - if(line.starts_with("#include") || line.starts_with("# include")) { - // Extract the filename from the #include directive. - // Handles: #include "foo.h", #include , # include "foo.h" - auto quote_start = line.find_first_of("\"<"); - auto quote_end = llvm::StringRef::npos; - if(quote_start != llvm::StringRef::npos) { - char close = (line[quote_start] == '"') ? '"' : '>'; - quote_end = line.find(close, quote_start + 1); - } - if(quote_start != llvm::StringRef::npos && quote_end != llvm::StringRef::npos) { - auto included = line.slice(quote_start + 1, quote_end); - auto included_filename = llvm::sys::path::filename(included); - if(included_filename == next_filename) { - include_line_start = line_start; - break; - } - } - } + chain_contents.emplace_back((*buf)->getBuffer()); + chain_entries.push_back({cur_path, chain_contents.back()}); + } - line_start = - (newline_pos == llvm::StringRef::npos) ? content_ref.size() + 1 : newline_pos + 1; + // Snapshot the header itself for other occurrences along the chain: + // its real path is remapped to the open buffer at compile time, so + // includes of it inside the prefix/suffix must point at a copy. + auto target_path = workspace.path_pool.resolve(chain.back()); + std::string self_snapshot_path; + std::uint64_t target_hash = 0; + auto preamble_dir = path::join(workspace.config.project.cache_dir, "header_context"); + if(auto target_buf = llvm::MemoryBuffer::getFile(target_path)) { + auto content = (*target_buf)->getBuffer(); + target_hash = llvm::xxh3_64bits(content); + self_snapshot_path = path::join(preamble_dir, std::format("{:016x}.self.h", target_hash)); + if(!llvm::sys::fs::exists(self_snapshot_path)) { + auto ec = llvm::sys::fs::create_directories(preamble_dir); + if(ec) { + LOG_WARN("resolve_header_context: cannot create dir {}: {}", + preamble_dir, + ec.message()); + return std::nullopt; + } + if(auto result = fs::write(self_snapshot_path, content); !result) { + LOG_WARN("resolve_header_context: cannot write snapshot {}: {}", + self_snapshot_path, + result.error().message()); + return std::nullopt; + } } + } - // Emit a #line marker then all content before the include line. - preamble += std::format("#line 1 \"{}\"\n", cur_path.str()); - if(include_line_start != std::string::npos) { - preamble += content_ref.substr(0, include_line_start).str(); - } else { - // No matching include line found — emit the whole file to be safe. - LOG_DEBUG("resolve_header_context: include line for {} not found in {}, emitting full", - next_filename, - cur_path); - preamble += content; - } + if(!self_snapshot_path.empty()) { + workspace.synthesized_hosts[self_snapshot_path] = host_path_id; + } + + auto synthesized = + synthesize_context(chain_entries, target_path, resolver, occurrence, self_snapshot_path); + if(!synthesized) { + LOG_WARN("resolve_header_context: cannot match include chain for {} (host={})", + target_path, + host_path); + return std::nullopt; } + auto& preamble = synthesized->prefix; // Hash the preamble and write to cache directory. auto preamble_hash = llvm::xxh3_64bits(llvm::StringRef(preamble)); auto preamble_filename = std::format("{:016x}.h", preamble_hash); - auto preamble_dir = path::join(workspace.config.project.cache_dir, "header_context"); auto preamble_path = path::join(preamble_dir, preamble_filename); if(!llvm::sys::fs::exists(preamble_path)) { @@ -554,8 +724,53 @@ std::optional Compiler::resolve_header_context(std::uint32_t preamble_path, header_path_id); } + workspace.synthesized_hosts[preamble_path] = host_path_id; + + // The suffix restores everything after the include position (closing + // braces of enums/functions the fragment is embedded in). Injected by + // appending one #include line to the header's buffer at compile time. + std::string suffix_path; + if(!synthesized->suffix.empty()) { + auto suffix_hash = llvm::xxh3_64bits(llvm::StringRef(synthesized->suffix)); + suffix_path = path::join(preamble_dir, std::format("{:016x}.suffix.h", suffix_hash)); + if(!llvm::sys::fs::exists(suffix_path)) { + if(auto result = fs::write(suffix_path, synthesized->suffix); !result) { + LOG_WARN("resolve_header_context: cannot write suffix {}: {}", + suffix_path, + result.error().message()); + return std::nullopt; + } + } + workspace.synthesized_hosts[suffix_path] = host_path_id; + } - return HeaderFileContext{host_path_id, preamble_path, preamble_hash}; + // Snapshot the chain files for staleness detection: their content lives + // inside the synthesized preamble, so clang's own dependency tracking + // never sees them. Hash the buffers already read above — re-reading from + // disk could capture content newer than what the preamble embeds. + DepsSnapshot deps; + deps.build_at = build_at; + llvm::SmallVector chain_ids(chain.begin(), chain.end() - 1); + deps.path_ids.assign(chain_ids.begin(), chain_ids.end()); + deps.hashes.reserve(chain_contents.size() + 1); + for(auto& content: chain_contents) { + deps.hashes.push_back(llvm::xxh3_64bits(content)); + } + if(!self_snapshot_path.empty()) { + // The self-snapshot mirrors the header's disk state; re-synthesize + // when it changes so other-occurrence expansions stay current. + deps.path_ids.push_back(chain.back()); + deps.hashes.push_back(target_hash); + } + + return HeaderContext{host_path_id, + preamble_path, + preamble_hash, + std::move(suffix_path), + occurrence.value_or(0), + std::move(host_command_hash), + std::move(chain_ids), + std::move(deps)}; } std::string uri_to_path(const std::string& uri) { @@ -610,7 +825,8 @@ static protocol::Diagnostic make_inferred_command_diagnostic(CommandSource sourc void Compiler::publish_diagnostics(const std::string& uri, int version, const kota::codec::RawValue& diagnostics_json, - CommandSource source) { + CommandSource source, + std::optional line_limit) { if(!peer) return; std::vector diagnostics; @@ -621,6 +837,15 @@ void Compiler::publish_diagnostics(const std::string& uri, } } + // Suffix injection appends an #include past the user's EOF; errors in + // host code after the include point remap onto those phantom lines. + // They describe the includer, not the document — drop them. + if(line_limit.has_value()) { + std::erase_if(diagnostics, [&](const protocol::Diagnostic& d) { + return d.range.start.line >= *line_limit; + }); + } + // Guidance (and only when it can explain something): an exact CDB match // never gets the note, and neither does a guessed command that worked. if(source != CommandSource::CDBExact && std::ranges::any_of(diagnostics, is_file_not_found)) { @@ -634,6 +859,57 @@ void Compiler::publish_diagnostics(const std::string& uri, peer->send_notification(params); } +/// Append the header context's suffix as one trailing #include line: the +/// suffix content (everything after the include position along the chain) +/// lives in its own file so features never see it, while the token stream +/// still closes any braces the fragment is embedded in. The single extra +/// line sits past the editor's EOF and is invisible to the client. +void Compiler::append_suffix_include(const Session& session, std::string& text) { + if(!session.header_context.has_value() || session.header_context->suffix_path.empty()) { + return; + } + if(!text.ends_with('\n')) { + text += '\n'; + } + text += "#include \""; + // Escape like preamble_synthesis's line markers: Windows separators + // must survive the preprocessor's string literal parsing. + for(char c: session.header_context->suffix_path) { + if(c == '\\' || c == '"') { + text += '\\'; + } + text += c; + } + text += "\"\n"; +} + +/// Push the file's preprocessor-inactive regions (clice/inactiveRegions). +/// Sent after every compile so a context switch immediately re-dims the +/// regions selected away by the new preprocessor state. +void Compiler::publish_inactive_regions(const std::string& uri, + const Session& session, + llvm::ArrayRef regions) { + if(!peer) { + return; + } + auto map = session.line_map(); + ext::InactiveRegionsParams params; + params.uri = uri; + params.regions.reserve(regions.size() / 2); + for(std::size_t i = 0; i + 1 < regions.size(); i += 2) { + auto start = map.to_position(regions[i]); + auto end = map.to_position(regions[i + 1]); + if(!start || !end) { + continue; + } + protocol::Range range; + range.start = *start; + range.end = *end; + params.regions.push_back(range); + } + peer->send_notification("clice/inactiveRegions", params); +} + void Compiler::clear_diagnostics(const std::string& uri) { if(!peer) return; @@ -650,13 +926,23 @@ kota::task Compiler::ensure_pch(Session& session, auto path = workspace.path_pool.resolve(path_id); auto& text = session.text; auto bound = compute_preamble_bound(text); - if(bound == 0) { - // No preamble directives — PCH would be empty. Clear any stale entry. - workspace.pch_cache.erase(path_id); + bool has_prefix = + session.header_context.has_value() && !session.header_context->preamble_path.empty(); + if(bound == 0 && !has_prefix) { + // No preamble directives and no injected -include — PCH would be + // empty. Self-contained header contexts land here too: they borrow + // a command but inject nothing. session.pch_ref.reset(); co_return true; } + // With a synthesized prefix, the PCH is worth building even at + // bound == 0: the -include'd preamble file is processed via the + // predefines buffer and lands in the PCH, so the (potentially huge) + // prefix is not re-parsed on every edit. The -include flag is part of + // the canonicalized arguments below, and the preamble file name is its + // content hash, so the key tracks prefix changes automatically. + // Key the PCH by preamble text plus the frontend-relevant compile flags, // so files with the same preamble text but different flags (-D, -I, -std) // produce separate PCHs. The source file path stays out of the key so @@ -672,49 +958,61 @@ kota::task Compiler::ensure_pch(Session& session, preamble_text, canonicalize(arguments, ArgsProfile::Frontend)}); - // Reuse existing PCH if the key and deps haven't changed. The store - // lookup refreshes the blob's LRU position and catches eviction. + // Reuse an existing PCH with the same content key — possibly built for + // a different file. The store lookup refreshes the blob's LRU position + // and catches eviction. llvm::StringRef pch_miss = "no_entry"; - if(auto it = workspace.pch_cache.find(path_id); it != workspace.pch_cache.end()) { + if(auto it = workspace.pch_cache.find(pch_key); it != workspace.pch_cache.end()) { auto& st = it->second; - if(st.key != pch_key) { - pch_miss = "key_changed"; - } else if(st.path.empty()) { + bool in_store = workspace.store && workspace.store->lookup("pch", pch_key); + if(st.path.empty()) { pch_miss = "incomplete_entry"; - } else if(!(workspace.store && workspace.store->lookup("pch", pch_key))) { + } else if(!in_store) { pch_miss = "evicted"; } else if(deps_changed(workspace.path_pool, st.deps)) { pch_miss = "deps_changed"; } else { - st.bound = bound; - session.pch_ref = Session::PCHRef{path_id, pch_key, bound}; + session.pch_ref = Session::PCHRef{pch_key, bound}; LOG_PERF("cache", "ns=pch event=hit key={} file={}", pch_key, path); co_return true; } + // Blob evicted by the store's LRU: drop the metadata too, or the + // content-keyed map grows for the server's lifetime. + if(!in_store && !st.building) { + workspace.pch_cache.erase(it); + } } LOG_PERF("cache", "ns=pch event=miss reason={} key={} file={}", pch_miss, pch_key, path); - // Preamble incomplete (user still typing) — defer rebuild, reuse old PCH if available. + // Preamble incomplete (user still typing) — defer rebuild, keep using + // the session's previous PCH if it is still available. if(!is_preamble_complete(text, bound)) { LOG_DEBUG("Preamble incomplete for {}, deferring PCH rebuild", path); - co_return workspace.pch_cache.count(path_id) && !workspace.pch_cache[path_id].path.empty(); + if(session.pch_ref.has_value()) { + auto it = workspace.pch_cache.find(session.pch_ref->key); + co_return it != workspace.pch_cache.end() && !it->second.path.empty(); + } + co_return false; } - // If another coroutine is already building PCH for this file, wait for it. - if(auto it = workspace.pch_cache.find(path_id); + // If another coroutine is already building a PCH with this key + // (same file, or another file with an identical preamble), wait for it. + if(auto it = workspace.pch_cache.find(pch_key); it != workspace.pch_cache.end() && it->second.building) { co_await it->second.building->wait(); - if(auto it2 = workspace.pch_cache.find(path_id); it2 != workspace.pch_cache.end()) { - session.pch_ref = Session::PCHRef{path_id, it2->second.key, it2->second.bound}; + if(auto it2 = workspace.pch_cache.find(pch_key); + it2 != workspace.pch_cache.end() && !it2->second.path.empty()) { + session.pch_ref = Session::PCHRef{pch_key, it2->second.bound}; + co_return true; } - co_return workspace.pch_cache.count(path_id) && !workspace.pch_cache[path_id].path.empty(); + co_return false; } // Register in-flight build so concurrent requests wait on us. The // guard wakes them on every exit, including cancellation mid-await. auto completion = std::make_shared(); - workspace.pch_cache[path_id].building = completion; - BuildingGuard guard{workspace, path_id, completion}; + workspace.pch_cache[pch_key].building = completion; + BuildingGuard guard{workspace, pch_key, completion}; if(!workspace.store) { LOG_WARN("PCH build skipped: cache store is unavailable"); @@ -760,14 +1058,15 @@ kota::task Compiler::ensure_pch(Session& session, co_return false; } - auto& st = workspace.pch_cache[path_id]; + auto& st = workspace.pch_cache[pch_key]; st.path = committed.value().value(); st.bound = bound; - st.key = pch_key; st.deps = capture_deps_snapshot(workspace.path_pool, result.value().deps); st.document_links_json = std::move(result.value().pch_links_json); + st.inactive_regions = std::move(result.value().inactive_regions); + st.open_conditionals = std::move(result.value().open_conditionals); - session.pch_ref = Session::PCHRef{path_id, pch_key, bound}; + session.pch_ref = Session::PCHRef{pch_key, bound}; LOG_INFO("PCH built for {}: {}", path, st.path); @@ -872,8 +1171,9 @@ kota::task Compiler::ensure_deps(Session& session, // Build or reuse PCH. auto pch_ok = co_await ensure_pch(session, directory, arguments); - if(pch_ok) { - if(auto pch_it = workspace.pch_cache.find(path_id); pch_it != workspace.pch_cache.end()) { + if(pch_ok && session.pch_ref.has_value()) { + if(auto pch_it = workspace.pch_cache.find(session.pch_ref->key); + pch_it != workspace.pch_cache.end()) { pch = {pch_it->second.path, pch_it->second.bound}; } } @@ -889,9 +1189,15 @@ bool Compiler::is_stale(const Session& session) { if(session.ast_deps.has_value() && deps_changed(workspace.path_pool, *session.ast_deps)) return true; + // Chain files of a header context are embedded in the synthesized + // preamble, invisible to ast_deps — check them explicitly. + if(session.header_context.has_value() && + deps_changed(workspace.path_pool, session.header_context->deps)) + return true; + // Check PCH staleness via the session's pch_ref. if(session.pch_ref.has_value()) { - auto pch_it = workspace.pch_cache.find(session.pch_ref->path_id); + auto pch_it = workspace.pch_cache.find(session.pch_ref->key); if(pch_it != workspace.pch_cache.end() && deps_changed(workspace.path_pool, pch_it->second.deps)) return true; @@ -929,76 +1235,152 @@ kota::task<> Compiler::run_compile(std::shared_ptr session) { auto uri = lsp::URI::from_file_path(file_path); std::string uri_str = uri.has_value() ? uri->str() : file_path; - worker::CompileParams params; - params.path = file_path; - params.version = session->version; - params.text = session->text; - auto source = fill_compile_args(file_path, params.directory, params.arguments, session.get()); - - bool deps_ok = co_await ensure_deps(*session, - params.directory, - params.arguments, - params.pch, - params.pcms, - pc->deps_scope.token()); - pc->deps_done = true; - if(!deps_ok) { - LOG_WARN("Dependency preparation failed for {}, skipping compile", uri_str); - finish_compile(); - co_return; - } + // At most two rounds: a header with unknown self-containment compiles + // without a prefix first; if the diagnostics indicate missing includer + // context, the second round re-compiles with a synthesized prefix. + // The trial's diagnostics are never published. + for(int attempt = 0; attempt < 2; ++attempt) { + worker::CompileParams params; + params.path = file_path; + params.version = session->version; + params.text = session->text; + auto source = + fill_compile_args(file_path, params.directory, params.arguments, session.get()); + + // The line the appended suffix #include lands on — anything at or + // past it is phantom text the user cannot see. + std::optional suffix_line_limit; + if(session->header_context.has_value() && !session->header_context->suffix_path.empty()) { + auto newlines = std::ranges::count(params.text, '\n'); + suffix_line_limit = + static_cast(newlines + (params.text.ends_with('\n') ? 0 : 1)); + } + append_suffix_include(*session, params.text); + + bool deps_ok = co_await ensure_deps(*session, + params.directory, + params.arguments, + params.pch, + params.pcms, + pc->deps_scope.token()); + pc->deps_done = true; + if(!deps_ok) { + LOG_WARN("Dependency preparation failed for {}, skipping compile", uri_str); + finish_compile(); + co_return; + } - if(session->generation != gen) { - LOG_INFO("ensure_compiled: superseded before send ({} vs {}) for {}", - session->generation, - gen, - uri_str); - finish_compile(); - co_return; - } + if(session->generation != gen) { + LOG_INFO("ensure_compiled: superseded before send ({} vs {}) for {}", + session->generation, + gen, + uri_str); + finish_compile(); + co_return; + } - auto result = co_await pool.send_stateful(pid, params); + // Seed the inactive-region scan with the conditional stack the + // PCH's preamble left open (a #if cut by the bound). Copy the state + // out: concurrent compiles can insert into pch_cache across the + // await below and rehash the map from under a held pointer. + std::vector pch_inactive; + if(session->pch_ref.has_value()) { + if(auto it = workspace.pch_cache.find(session->pch_ref->key); + it != workspace.pch_cache.end()) { + pch_inactive = it->second.inactive_regions; + params.open_conditionals = it->second.open_conditionals; + } + } - if(session->generation != gen) { - LOG_INFO("ensure_compiled: generation mismatch ({} vs {}) for {}", - session->generation, - gen, - uri_str); - finish_compile(); - co_return; - } + auto result = co_await pool.send_stateful(pid, params); - if(!result.has_value()) { - if(worker::is_operational_error(result.error())) { - LOG_WARN("Compile did not complete for {}: {}", uri_str, result.error().message); - } else { - // The worker accepts arbitrary user code; a non-operational - // failure at this layer is IPC/worker breakage, never a - // user-code problem. - LOG_ANOMALY(CompileFail, "Compile failed for {}: {}", uri_str, result.error().message); + if(session->generation != gen) { + LOG_INFO("ensure_compiled: generation mismatch ({} vs {}) for {}", + session->generation, + gen, + uri_str); + finish_compile(); + co_return; + } + + if(!result.has_value()) { + if(worker::is_operational_error(result.error())) { + LOG_WARN("Compile did not complete for {}: {}", uri_str, result.error().message); + } else { + // The worker accepts arbitrary user code; a non-operational + // failure at this layer is IPC/worker breakage, never a + // user-code problem. + LOG_ANOMALY(CompileFail, + "Compile failed for {}: {}", + uri_str, + result.error().message); + } + clear_diagnostics(uri_str); + finish_compile(); + co_return; + } + + // Self-containment trial verdict. Scored once per settled input + // state: trial_done is reset whenever compile inputs change for + // reasons other than buffer edits, so a dependency change re-runs + // the trial while ordinary typing never does. Only NeedsContext is + // persisted — SelfContained is recorded in memory alone (dependency + // changes erase it) so queryContext can dedup identical-flag hosts + // once the verdict is actually earned, never on a guess. + if(attempt == 0 && !session->trial_done && session->header_context.has_value() && + session->header_context->preamble_path.empty() && + workspace.header_mode(file_path, pid) == HeaderMode::Unknown) { + std::vector diagnostics; + if(!result.value().diagnostics.empty()) { + [[maybe_unused]] auto status = + kota::codec::json::from_json(result.value().diagnostics.data, diagnostics); + } + session->trial_done = true; + workspace.header_modes[pid] = HeaderMode::SelfContained; + + if(indicates_missing_context(diagnostics)) { + LOG_INFO("Header {} needs includer context, re-compiling with prefix", uri_str); + workspace.header_modes[pid] = HeaderMode::NeedsContext; + workspace.header_mode_hashes[pid] = hash_file(file_path); + workspace.save_cache(); + session->header_context.reset(); + session->pch_ref.reset(); + continue; + } } - clear_diagnostics(uri_str); - finish_compile(); - co_return; - } - session->ast_dirty = false; - pc->succeeded = true; - record_deps(*session, result.value().deps); + session->ast_dirty = false; + pc->succeeded = true; + record_deps(*session, result.value().deps); - if(!result.value().tu_index_data.empty()) { - auto tu_index = index::TUIndex::from(result.value().tu_index_data.data()); - session->file_index = std::move(tu_index.main_file_index); - session->symbols = std::move(tu_index.symbols); + if(!result.value().tu_index_data.empty()) { + auto tu_index = index::TUIndex::from(result.value().tu_index_data.data()); + session->file_index = std::move(tu_index.main_file_index); + session->symbols = std::move(tu_index.symbols); + } + + auto version = session->version; + finish_compile(); + + LOG_PERF("request", "kind=Compile file={} total_ms={}", file_path, timer.ms()); + publish_diagnostics(uri_str, + version, + result.value().diagnostics, + source, + suffix_line_limit); + // The preamble's share lives with the PCH; the compile result + // covers the content past the bound. Publish both. + auto inactive = std::move(pch_inactive); + inactive.insert(inactive.end(), + result.value().inactive_regions.begin(), + result.value().inactive_regions.end()); + publish_inactive_regions(uri_str, *session, inactive); + if(on_indexing_needed) + on_indexing_needed(); + co_return; } - auto version = session->version; finish_compile(); - - LOG_PERF("request", "kind=Compile file={} total_ms={}", file_path, timer.ms()); - publish_diagnostics(uri_str, version, result.value().diagnostics, source); - if(on_indexing_needed) - on_indexing_needed(); } /// AST and diagnostics have been published to the client. @@ -1038,7 +1420,10 @@ kota::task Compiler::ensure_compiled(std::shared_ptr session) { if(!is_stale(*session)) { co_return true; } + // Dependency change, not a buffer edit: re-run the trial too. session->ast_dirty = true; + session->trial_done = false; + workspace.forget_self_contained(path_id); } // If an up-to-date compile is already in flight, wait for it. @@ -1155,6 +1540,7 @@ Compiler::RawResult Compiler::forward_build(worker::BuildKind kind, wp.version = session->version; wp.text = session->text; fill_compile_args(path, wp.directory, wp.arguments, session.get()); + append_suffix_include(*session, wp.text); ScopedTimer timer; if(!co_await ensure_deps(*session, wp.directory, wp.arguments, wp.pch, wp.pcms)) { diff --git a/src/server/compiler/compiler.h b/src/server/compiler/compiler.h index cf25df3da9..1c561e2745 100644 --- a/src/server/compiler/compiler.h +++ b/src/server/compiler/compiler.h @@ -147,13 +147,21 @@ class Compiler { /// compile command was not an exact CDB match and the diagnostics contain /// file-not-found class errors, a file-top guidance diagnostic explaining /// the inferred command is merged into the same publish. + static void append_suffix_include(const Session& session, std::string& text); + + void publish_inactive_regions(const std::string& uri, + const Session& session, + llvm::ArrayRef regions); + void publish_diagnostics(const std::string& uri, int version, const kota::codec::RawValue& diags, - CommandSource source); + CommandSource source, + std::optional line_limit = std::nullopt); - std::optional resolve_header_context(std::uint32_t header_path_id, - Session* session); + std::optional resolve_header_context(std::uint32_t header_path_id, + Session* session, + bool synthesize); bool fill_header_context_args(llvm::StringRef path, std::uint32_t path_id, diff --git a/src/server/protocol/extension.h b/src/server/protocol/extension.h index 7ed2cbbd44..14905d3fcd 100644 --- a/src/server/protocol/extension.h +++ b/src/server/protocol/extension.h @@ -1,15 +1,30 @@ #pragma once +#include #include #include #include +#include "kota/ipc/lsp/protocol.h" + namespace clice::ext { struct ContextItem { std::string label; std::string description; + + /// Host source file (header contexts) or the file itself (source + /// compile configurations). std::string uri; + + /// For header contexts: which include of the header in its direct + /// includer this context represents (0-based, in directive order). + /// Present only when the header is included more than once. + std::optional occurrence; + + /// For source compile configurations: canonical hash identifying the + /// CDB entry. Pass it back in switchContext to select this entry. + std::optional command_hash; }; struct QueryContextParams { @@ -20,6 +35,10 @@ struct QueryContextParams { struct QueryContextResult { std::vector contexts; int total = 0; + + /// Workspace state generation these results were computed against. + /// Pass it back in switchContext to detect stale listings. + std::uint64_t epoch = 0; }; struct CurrentContextParams { @@ -33,10 +52,33 @@ struct CurrentContextResult { struct SwitchContextParams { std::string uri; std::string context_uri; + + /// Include occurrence to pin (header contexts, 0-based). + std::optional occurrence; + + /// Canonical CDB entry hash to pin (source files with multiple + /// compile commands). + std::optional command_hash; + + /// Epoch of the queryContext result this choice came from. When set + /// and the workspace has changed since, the switch is rejected with + /// stale = true and the client should re-query. + std::optional epoch; }; struct SwitchContextResult { bool success = false; + + /// The request referenced an outdated queryContext listing. + bool stale = false; +}; + +/// Pushed as the clice/inactiveRegions notification after each compile: +/// the preprocessor-inactive regions of the file under its current +/// compilation context. Clients typically render them dimmed. +struct InactiveRegionsParams { + std::string uri; + std::vector regions; }; } // namespace clice::ext diff --git a/src/server/protocol/worker.h b/src/server/protocol/worker.h index edefdc9cba..e48a945327 100644 --- a/src/server/protocol/worker.h +++ b/src/server/protocol/worker.h @@ -68,6 +68,10 @@ struct CompileParams { std::vector arguments; std::pair pch; std::unordered_map pcms; + + /// Conditional levels left open by the PCH's preamble (1 = branch + /// inactive), seeding the main compile's inactive-region scan. + std::vector open_conditionals; }; struct CompileResult { @@ -78,6 +82,12 @@ struct CompileResult { std::vector deps; /// Serialized TUIndex for the main file (interested_only=true). std::string tu_index_data; + + /// Preprocessor-inactive regions as flat byte-offset pairs + /// [begin0, end0, begin1, end1, ...] in the main file. Covers only + /// the content past the PCH bound; the preamble's share lives in + /// PCHState (analogous to document links). + std::vector inactive_regions; }; enum class Priority : uint8_t { High, Low }; @@ -136,8 +146,13 @@ struct BuildResult { std::string output_path; ///< PCH or PCM path std::vector deps; std::string tu_index_data; - std::string pch_links_json; ///< Pre-serialized DocumentLink[] from PCH - kota::codec::RawValue result_json; ///< Completion/SignatureHelp result + std::string pch_links_json; + + /// Inactive regions within the preamble region (flat offset pairs) + /// and the conditional stack still open at the bound. + std::vector inactive_regions; + std::vector open_conditionals; ///< Pre-serialized DocumentLink[] from PCH + kota::codec::RawValue result_json; ///< Completion/SignatureHelp result }; struct DocumentUpdateParams { diff --git a/src/server/service/lsp_client.cpp b/src/server/service/lsp_client.cpp index fd439cdc6b..257fdf26f8 100644 --- a/src/server/service/lsp_client.cpp +++ b/src/server/service/lsp_client.cpp @@ -6,6 +6,7 @@ #include #include +#include "command/argument_parser.h" #include "semantic/symbol_kind.h" #include "server/protocol/extension.h" #include "server/protocol/worker.h" @@ -13,12 +14,15 @@ #include "support/anomaly.h" #include "support/filesystem.h" #include "support/logging.h" +#include "syntax/preamble_synthesis.h" #include "kota/codec/json/json.h" #include "kota/ipc/lsp/position.h" #include "kota/ipc/lsp/protocol.h" #include "kota/ipc/lsp/uri.h" #include "kota/meta/enum.h" +#include "llvm/ADT/StringSet.h" +#include "llvm/Support/MemoryBuffer.h" #include "llvm/Support/Path.h" #include "llvm/Support/Process.h" @@ -195,6 +199,52 @@ LSPClient::LSPClient(MasterServer& server, kota::ipc::JsonPeer& peer) : server(s session->text = params.text_document.text; session->line_starts = lsp::build_line_starts(session->text); + // Restore a context choice persisted from an earlier session. The + // CDB or include graph may have changed while the server was down — + // validate before adopting, since a stale active_context suppresses + // automatic host resolution and strands the file on the fallback + // command. + if(auto it = srv.workspace.saved_contexts.find(path_id); + it != srv.workspace.saved_contexts.end()) { + auto& ws = srv.workspace; + auto& saved = it->second; + auto entry_has_hash = [&ws](llvm::StringRef entry_path, llvm::StringRef hash) { + std::vector rule_append, rule_remove; + ws.config.match_rules(entry_path, rule_append, rule_remove); + for(auto& cmd: + ws.cdb.lookup(entry_path, {.remove = rule_remove, .append = rule_append})) { + if(canonical_command_hash(cmd.to_string_argv(), cmd.resolved.directory) == + hash) { + return true; + } + } + return false; + }; + + bool valid = false; + if(saved.host_path_id != no_path_id) { + auto host_path = ws.path_pool.resolve(saved.host_path_id); + valid = + ws.cdb.has_entry(host_path) && + !ws.dep_graph.find_include_chain(saved.host_path_id, path_id).empty() && + (saved.command_hash.empty() || entry_has_hash(host_path, saved.command_hash)); + if(valid) { + session->active_context = Session::ActiveContext{saved.host_path_id, + saved.occurrence, + saved.command_hash}; + } + } else if(!saved.command_hash.empty()) { + valid = ws.cdb.has_entry(path) && entry_has_hash(path, saved.command_hash); + if(valid) { + session->active_command = saved.command_hash; + } + } + if(!valid) { + LOG_INFO("didOpen: dropping stale saved context for {}", path); + srv.workspace.saved_contexts.erase(it); + } + } + session->generation++; LOG_DEBUG("didOpen: {} (v{})", path, params.text_document.version); @@ -344,8 +394,10 @@ LSPClient::LSPClient(MasterServer& server, kota::ipc::JsonPeer& peer) : server(s auto& links = result.value(); if(session->pch_ref) { auto& pch_cache = srv.workspace.pch_cache; - auto pch_it = pch_cache.find(session->pch_ref->path_id); - if(pch_it != pch_cache.end() && !pch_it->second.document_links_json.empty()) { + auto pch_it = pch_cache.find(session->pch_ref->key); + // size() > 2 skips both "" and an empty "[]": splicing an empty + // array into a non-empty one would leave a trailing comma. + if(pch_it != pch_cache.end() && pch_it->second.document_links_json.size() > 2) { auto& pch_json = pch_it->second.document_links_json; if(!links.data.empty() && links.data != "null" && links.data.size() > 2) { links.data.pop_back(); @@ -593,67 +645,134 @@ LSPClient::LSPClient(MasterServer& server, kota::ipc::JsonPeer& peer) : server(s co_return to_raw(results); }); + // ── Compilation context helpers ───────────────────────────────── + + // Human-readable summary of the distinguishing flags of a command. + auto flags_label = [](const CompileCommand& cmd) { + auto argv = cmd.to_argv(); + std::string desc; + for(std::size_t j = 0; j < argv.size(); ++j) { + llvm::StringRef a(argv[j]); + if(a.starts_with("-D") || a.starts_with("-O") || a.starts_with("-std=") || + a.starts_with("-g")) { + if(!desc.empty()) + desc += ' '; + desc += argv[j]; + if((a == "-D" || a == "-O") && j + 1 < argv.size()) { + desc += argv[++j]; + } + } + } + return desc; + }; + + // How many includes of `target_id` the direct includer along the + auto count_occurrences = [this](std::uint32_t host_id, std::uint32_t target_id) { + return this->server.workspace.count_occurrences(host_id, target_id); + }; + peer.on_request( "clice/queryContext", - [this](RequestContext& ctx, const ext::QueryContextParams& params) -> RawResult { + [this, flags_label, count_occurrences](RequestContext& ctx, + const ext::QueryContextParams& params) -> RawResult { auto& srv = this->server; + auto& ws = srv.workspace; auto path = uri_to_path(params.uri); - auto path_id = srv.workspace.path_pool.intern(path); + auto path_id = ws.path_pool.intern(path); int offset_val = std::max(0, params.offset.value_or(0)); constexpr int page_size = 10; ext::QueryContextResult result; std::vector all_items; - auto& ws = srv.workspace; + // Contexts that would produce identical compilation results are + // collapsed: identical canonical flags mean an identical compile + // — but only for headers CONFIRMED self-contained. A header that + // needs includer context gets a different synthesized prefix per + // host, and an un-trialed header may turn out the same way, so + // every host stays a distinct context for both. + llvm::StringSet<> seen_configs; + bool dedup_hosts = ws.header_mode(path, path_id) == HeaderMode::SelfContained; + auto hosts = ws.dep_graph.find_host_sources(path_id); - for(auto host_id: hosts) { + for(auto host_id: ws.rank_hosts(path_id, hosts)) { auto host_path = ws.path_pool.resolve(host_id); - auto host_cdb = ws.cdb.lookup(host_path); - if(host_cdb.empty()) + if(!ws.cdb.has_entry(host_path)) continue; auto host_uri_opt = lsp::URI::from_file_path(std::string(host_path)); if(!host_uri_opt) continue; - ext::ContextItem item; - item.label = llvm::sys::path::filename(host_path).str(); - item.description = std::string(host_path); - item.uri = host_uri_opt->str(); - all_items.push_back(std::move(item)); - } - if(hosts.empty()) { - auto entries = ws.cdb.lookup(path); - for(std::size_t i = 0; i < entries.size(); ++i) { - auto& cmd = entries[i]; - auto argv = cmd.to_argv(); - std::string desc; - for(std::size_t j = 0; j < argv.size(); ++j) { - llvm::StringRef a(argv[j]); - if(a.starts_with("-D") || a.starts_with("-O") || a.starts_with("-std=") || - a.starts_with("-g")) { - if(!desc.empty()) - desc += ' '; - desc += argv[j]; - if((a == "-D" || a == "-O") && j + 1 < argv.size()) { - desc += argv[++j]; - } + // A multi-configuration host contributes one context per + // CDB entry: each configuration compiles the header under + // different preprocessor state. + std::vector host_append, host_remove; + ws.config.match_rules(host_path, host_append, host_remove); + auto cmds = + ws.cdb.lookup(host_path, {.remove = host_remove, .append = host_append}); + auto occurrences = count_occurrences(host_id, path_id); + + for(auto& cmd: cmds) { + auto hash = + canonical_command_hash(cmd.to_string_argv(), cmd.resolved.directory); + if(dedup_hosts && !seen_configs.insert(hash).second) + continue; + + ext::ContextItem item; + item.label = llvm::sys::path::filename(host_path).str(); + if(cmds.size() > 1) { + auto desc = flags_label(cmd); + if(!desc.empty()) { + item.label = std::format("{} [{}]", item.label, desc); + } + item.command_hash = hash; + } + item.description = std::string(host_path); + item.uri = host_uri_opt->str(); + + // A guard-less header can be included several times by + // one host — each occurrence is a distinct context. + if(occurrences > 1) { + for(std::uint32_t n = 0; n < occurrences; ++n) { + auto occ_item = item; + occ_item.label = std::format("{} (#{})", item.label, n + 1); + occ_item.occurrence = n; + all_items.push_back(std::move(occ_item)); } + } else { + all_items.push_back(std::move(item)); } - if(desc.empty()) - desc = std::format("config #{}", i); + } + } - auto uri_opt = lsp::URI::from_file_path(std::string(path)); - if(!uri_opt) + // Real entries only: lookup() would synthesize a default command + // even for unknown files, offering a bogus context that + // switchContext would then reject. Offered even when hosts + // exist, so a host override can be switched back to the file's + // own command. + if(ws.cdb.has_entry(path)) { + std::vector rule_append, rule_remove; + ws.config.match_rules(path, rule_append, rule_remove); + auto entries = ws.cdb.lookup(path, {.remove = rule_remove, .append = rule_append}); + auto uri_opt = lsp::URI::from_file_path(std::string(path)); + for(std::size_t i = 0; uri_opt && i < entries.size(); ++i) { + auto& cmd = entries[i]; + auto hash = + canonical_command_hash(cmd.to_string_argv(), cmd.resolved.directory); + if(!seen_configs.insert(hash).second) continue; + + auto desc = flags_label(cmd); ext::ContextItem item; - item.label = desc; + item.label = desc.empty() ? std::format("config #{}", i) : desc; item.description = cmd.resolved.directory.str(); item.uri = uri_opt->str(); + item.command_hash = std::move(hash); all_items.push_back(std::move(item)); } } + result.epoch = ws.context_epoch; result.total = static_cast(all_items.size()); int end = std::min(offset_val + page_size, static_cast(all_items.size())); for(int i = offset_val; i < end; ++i) { @@ -664,7 +783,8 @@ LSPClient::LSPClient(MasterServer& server, kota::ipc::JsonPeer& peer) : server(s peer.on_request( "clice/currentContext", - [this](RequestContext& ctx, const ext::CurrentContextParams& params) -> RawResult { + [this, flags_label](RequestContext& ctx, + const ext::CurrentContextParams& params) -> RawResult { auto& srv = this->server; auto path = uri_to_path(params.uri); auto path_id = srv.workspace.path_pool.intern(path); @@ -672,48 +792,149 @@ LSPClient::LSPClient(MasterServer& server, kota::ipc::JsonPeer& peer) : server(s ext::CurrentContextResult result; auto session = srv.find_session(path_id); if(session && session->active_context) { - auto ctx_path = srv.workspace.path_pool.resolve(*session->active_context); + auto& active = *session->active_context; + auto ctx_path = srv.workspace.path_pool.resolve(active.host_path_id); auto ctx_uri_opt = lsp::URI::from_file_path(std::string(ctx_path)); if(ctx_uri_opt) { ext::ContextItem item; item.label = llvm::sys::path::filename(ctx_path).str(); + if(active.occurrence.value_or(0) > 0) { + item.label = std::format("{} (#{})", item.label, *active.occurrence + 1); + } item.description = std::string(ctx_path); item.uri = ctx_uri_opt->str(); + item.occurrence = active.occurrence; + if(!active.command_hash.empty()) { + item.command_hash = active.command_hash; + } result.context = std::move(item); } + } else if(session && session->active_command) { + auto& ws = srv.workspace; + ext::ContextItem item; + item.uri = params.uri; + item.command_hash = *session->active_command; + item.label = std::format("config {}", session->active_command->substr(0, 8)); + if(ws.cdb.has_entry(path)) { + std::vector rule_append, rule_remove; + ws.config.match_rules(path, rule_append, rule_remove); + for(auto& cmd: + ws.cdb.lookup(path, {.remove = rule_remove, .append = rule_append})) { + if(canonical_command_hash(cmd.to_string_argv(), cmd.resolved.directory) == + *session->active_command) { + auto desc = flags_label(cmd); + if(!desc.empty()) { + item.label = std::move(desc); + } + item.description = cmd.resolved.directory.str(); + break; + } + } + } + result.context = std::move(item); } co_return to_raw(result); }); peer.on_request( "clice/switchContext", - [this](RequestContext& ctx, const ext::SwitchContextParams& params) -> RawResult { + [this, count_occurrences](RequestContext& ctx, + const ext::SwitchContextParams& params) -> RawResult { auto& srv = this->server; + auto& ws = srv.workspace; auto path = uri_to_path(params.uri); - auto path_id = srv.workspace.path_pool.intern(path); + auto path_id = ws.path_pool.intern(path); auto context_path = uri_to_path(params.context_uri); - auto context_path_id = srv.workspace.path_pool.intern(context_path); + auto context_path_id = ws.path_pool.intern(context_path); ext::SwitchContextResult result; - auto& ws = srv.workspace; - auto context_cdb = ws.cdb.lookup(context_path); - if(context_cdb.empty()) { - result.success = false; + // A choice made against an outdated listing may reference + // contexts that no longer exist — make the client re-query. + if(params.epoch.has_value() && *params.epoch != ws.context_epoch) { + result.stale = true; co_return to_raw(result); } auto session = srv.find_session(path_id); if(!session) { - result.success = false; co_return to_raw(result); } - session->active_context = context_path_id; + // Validate that `hash` names a real CDB entry of `entry_path`. + auto has_command = [&](llvm::StringRef entry_path, llvm::StringRef hash) { + if(!ws.cdb.has_entry(entry_path)) { + return false; + } + std::vector rule_append, rule_remove; + ws.config.match_rules(entry_path, rule_append, rule_remove); + for(auto& cmd: + ws.cdb.lookup(entry_path, {.remove = rule_remove, .append = rule_append})) { + if(canonical_command_hash(cmd.to_string_argv(), cmd.resolved.directory) == + hash) { + return true; + } + } + return false; + }; + + if(context_path_id == path_id && params.command_hash.has_value()) { + // Pin one of the file's own CDB entries. + if(!has_command(path, *params.command_hash)) { + co_return to_raw(result); + } + session->active_command = *params.command_hash; + session->active_context.reset(); + } else { + // Pin a host source for a header: it must have a real CDB + // entry, actually (transitively) include this header, and — + // for multi-configuration hosts — own the pinned entry. + if(!ws.cdb.has_entry(context_path)) { + co_return to_raw(result); + } + if(ws.dep_graph.find_include_chain(context_path_id, path_id).empty()) { + co_return to_raw(result); + } + if(params.command_hash.has_value() && + !has_command(context_path, *params.command_hash)) { + co_return to_raw(result); + } + if(params.occurrence.has_value() && *params.occurrence > 0) { + auto count = count_occurrences(context_path_id, path_id); + if(count > 0 && *params.occurrence >= count) { + co_return to_raw(result); + } + } + session->active_context = Session::ActiveContext{context_path_id, + params.occurrence, + params.command_hash.value_or("")}; + session->active_command.reset(); + } + session->header_context.reset(); session->pch_ref.reset(); session->ast_deps.reset(); session->ast_dirty = true; + // The new context needs its own self-containment trial — a + // different host can change the macro environment. + session->trial_done = false; + ws.forget_self_contained(path_id); + // Invalidate any in-flight compile: without the bump it would + // clobber ast_dirty on completion and publish results for the + // old context, with nothing left for is_stale() to detect. + session->generation++; + + // Persist the choice across sessions. + SavedContext saved; + if(session->active_context) { + saved.host_path_id = session->active_context->host_path_id; + saved.occurrence = session->active_context->occurrence; + saved.command_hash = session->active_context->command_hash; + } else if(session->active_command) { + saved.command_hash = *session->active_command; + } + ws.saved_contexts[path_id] = std::move(saved); + ws.save_cache(); result.success = true; co_return to_raw(result); diff --git a/src/server/service/master_server.cpp b/src/server/service/master_server.cpp index 759f0590ae..e131de6d3f 100644 --- a/src/server/service/master_server.cpp +++ b/src/server/service/master_server.cpp @@ -20,6 +20,7 @@ #include "kota/ipc/lsp/uri.h" #include "kota/ipc/recording_transport.h" #include "kota/ipc/transport.h" +#include "llvm/ADT/STLExtras.h" #include "llvm/Support/FileSystem.h" #include "llvm/Support/Path.h" #include "llvm/Support/Process.h" @@ -166,22 +167,83 @@ void MasterServer::close_session(std::uint32_t path_id, kota::ipc::JsonPeer& pee } void MasterServer::on_file_saved(std::uint32_t path_id) { + // The saved file's own self-containment may have changed; re-evaluate + // on its next compile. + workspace.header_modes.erase(path_id); + workspace.header_mode_hashes.erase(path_id); + if(auto session = find_session(path_id)) { + session->trial_done = false; + } + auto dirtied = workspace.on_file_saved(path_id); for(auto dirty_id: dirtied) { auto session = find_session(dirty_id); if(session) { session->ast_dirty = true; + session->trial_done = false; + workspace.forget_self_contained(dirty_id); } else { indexer.enqueue(dirty_id); } } - for(auto& [path_id, session]: sessions) { - if(session->header_context && session->header_context->host_path_id == path_id) { + // Header sessions whose include chain contains the saved file must + // re-synthesize their preamble: it embeds the chain files' content, so + // neither the dependents cascade above nor clang's own dependency + // tracking catches this. Zeroing build_at forces deps_changed() to + // re-validate every chain file by content hash. Do NOT reset the + // context itself: an in-flight compile can clobber ast_dirty when it + // finishes, and the surviving snapshot is what lets is_stale() recover. + for(auto& [session_id, session]: sessions) { + if(session->header_context && llvm::is_contained(session->header_context->chain, path_id)) { + session->header_context->deps.build_at = 0; + session->ast_dirty = true; + session->trial_done = false; + // The chain change may have made the header self-contained + // (e.g. a dependency now provides the missing declarations); + // drop the persisted verdict so the trial can downgrade it. + workspace.header_modes.erase(session_id); + workspace.header_mode_hashes.erase(session_id); + } + } + + // A save can remove the include edge a user's context choice depends + // on. A stale active_context suppresses automatic host resolution, so + // it would strand the header on the fallback command (or silently pin + // its command hash to a different host) — drop it instead. The include + // graph was already rescanned above. + bool dropped_saved = false; + for(auto& [session_id, session]: sessions) { + if(!session->active_context.has_value()) { + continue; + } + auto host_id = session->active_context->host_path_id; + auto& occurrence = session->active_context->occurrence; + bool orphaned = workspace.dep_graph.find_include_chain(host_id, session_id).empty(); + // A pinned occurrence can vanish while other inclusions of the + // header survive (the chain stays non-empty) — recount it. + if(!orphaned && occurrence.has_value()) { + auto count = workspace.count_occurrences(host_id, session_id); + orphaned = count > 0 && *occurrence >= count; + } + if(orphaned) { + LOG_INFO("Dropping orphaned context choice for {}: host {} no longer includes it", + workspace.path_pool.resolve(session_id), + workspace.path_pool.resolve(host_id)); + session->active_context.reset(); session->header_context.reset(); + session->pch_ref.reset(); session->ast_dirty = true; + session->trial_done = false; + // Invalidate in-flight compiles so they cannot clobber the + // reset state when they finish (same as switchContext). + session->generation += 1; + dropped_saved |= workspace.saved_contexts.erase(session_id) > 0; } } + if(dropped_saved) { + workspace.save_cache(); + } indexer.schedule(); } diff --git a/src/server/service/session.h b/src/server/service/session.h index 781a89b4bc..7bcbbdb0e9 100644 --- a/src/server/service/session.h +++ b/src/server/service/session.h @@ -78,9 +78,8 @@ struct Session { /// The PCH itself is owned by Workspace (shared, content-addressed); /// Session only stores enough to locate and validate it. struct PCHRef { - std::uint32_t path_id = 0; ///< Key into Workspace.pch_cache. - std::string key; ///< CacheStore key at build time. - std::uint32_t bound = 0; ///< Preamble byte boundary. + std::string key; ///< Content key into Workspace.pch_cache. + std::uint32_t bound = 0; ///< Preamble byte boundary. }; std::optional pch_ref; @@ -89,13 +88,38 @@ struct Session { /// Used for two-layer staleness detection (mtime + content hash). std::optional ast_deps; + /// Whether this session's self-containment trial has settled. Reset + /// when compile inputs change for reasons other than buffer edits + /// (didSave cascades, chain invalidation, mtime staleness), so the + /// verdict re-evaluates on dependency changes but ordinary typing + /// errors never trigger a pointless prefix synthesis. + bool trial_done = false; + /// Compilation context for header files that lack their own CDB entry. /// Stores the host source file and synthesized preamble for this header. - std::optional header_context; + std::optional header_context; - /// User-selected compilation context override (via clice/switchContext). + /// User-selected header context override (via clice/switchContext). /// When set, overrides automatic header context resolution. - std::optional active_context; + struct ActiveContext { + std::uint32_t host_path_id = 0; ///< Host source file. + + /// Nth include of this header in its direct includer (0-based). + /// An explicitly chosen occurrence — even #0 — forces prefix + /// synthesis; no value means automatic. + std::optional occurrence; + + /// Canonical hash pinning one of the host's CDB entries when the + /// host is built under several configurations; empty = first. + std::string command_hash; + }; + + std::optional active_context; + + /// User-selected CDB entry for source files with multiple compile + /// commands (via clice/switchContext). Stores the canonical frontend + /// flags hash identifying the chosen entry. + std::optional active_command; /// Symbol index built from the latest compilation of this file's buffer. /// Used for queries (hover, goto, references) on this file. diff --git a/src/server/worker/stateful_worker.cpp b/src/server/worker/stateful_worker.cpp index 3786980b34..ce8c6d1f9d 100644 --- a/src/server/worker/stateful_worker.cpp +++ b/src/server/worker/stateful_worker.cpp @@ -9,6 +9,7 @@ #include "compile/compilation.h" #include "feature/feature.h" +#include "feature/inactive_regions.h" #include "index/tu_index.h" #include "server/protocol/worker.h" #include "server/worker/worker_common.h" @@ -123,82 +124,84 @@ class StatefulWorker { void StatefulWorker::register_handlers() { // === Compile === - peer.on_request( - [this](RequestContext& ctx, - const worker::CompileParams& params) -> RequestResult { - LOG_INFO("Compile request: path={}, version={}", params.path, params.version); - - // Hold shared_ptr so Evict can't destroy the entry mid-compile. - auto doc = get_or_create(params.path); - touch_lru(params.path); - - co_await doc->strand.lock(); - - // Copy params to doc AFTER acquiring the strand lock, so that - // concurrent Compile requests waiting on the strand don't - // overwrite our fields before we use them. - doc->version = params.version; - doc->text = params.text; - doc->directory = params.directory; - doc->arguments = params.arguments; - doc->pch = params.pch; - doc->pcms.clear(); - for(auto& [name, pcm_path]: params.pcms) { - doc->pcms.try_emplace(name, pcm_path); + peer.on_request([this](RequestContext& ctx, const worker::CompileParams& params) + -> RequestResult { + LOG_INFO("Compile request: path={}, version={}", params.path, params.version); + + // Hold shared_ptr so Evict can't destroy the entry mid-compile. + auto doc = get_or_create(params.path); + touch_lru(params.path); + + co_await doc->strand.lock(); + + // Copy params to doc AFTER acquiring the strand lock, so that + // concurrent Compile requests waiting on the strand don't + // overwrite our fields before we use them. + doc->version = params.version; + doc->text = params.text; + doc->directory = params.directory; + doc->arguments = params.arguments; + doc->pch = params.pch; + doc->pcms.clear(); + for(auto& [name, pcm_path]: params.pcms) { + doc->pcms.try_emplace(name, pcm_path); + } + + auto compile_result = co_await kota::queue([&]() -> worker::CompileResult { + ScopedTimer timer; + + CompilationParams cp; + cp.kind = CompilationKind::Content; + fill_args(cp, doc->directory, doc->arguments); + if(!doc->pch.first.empty()) { + cp.pch = doc->pch; + } + cp.add_remapped_file(params.path, doc->text); + for(auto& entry: doc->pcms) { + cp.pcms.try_emplace(entry.getKey(), entry.getValue()); } - auto compile_result = co_await kota::queue([&]() -> worker::CompileResult { - ScopedTimer timer; - - CompilationParams cp; - cp.kind = CompilationKind::Content; - fill_args(cp, doc->directory, doc->arguments); - if(!doc->pch.first.empty()) { - cp.pch = doc->pch; - } - cp.add_remapped_file(params.path, doc->text); - for(auto& entry: doc->pcms) { - cp.pcms.try_emplace(entry.getKey(), entry.getValue()); - } - - doc->unit = compile(cp); - doc->has_ast = true; - doc->dirty.store(false, std::memory_order_release); - - worker::CompileResult result; - result.version = doc->version; - if(doc->unit.completed() || doc->unit.fatal_error()) { - auto diags = feature::diagnostics(doc->unit); - auto json = kota::codec::json::to_json(diags); - result.diagnostics = kota::codec::RawValue{json ? std::move(*json) : "[]"}; - LOG_INFO("Compile done: path={}, {}ms, {} diags, fatal={}", - params.path, - timer.ms(), - diags.size(), - doc->unit.fatal_error()); - } else { - result.diagnostics = kota::codec::RawValue{"[]"}; - LOG_WARN("Compile incomplete: path={}, {}ms", params.path, timer.ms()); - } - result.memory_usage = 0; // TODO: query actual memory - if(doc->unit.completed()) { - result.deps = doc->unit.deps(); - - // Build index for main file only (interested_only=true). - auto tu_index = index::TUIndex::build(doc->unit, true); - llvm::raw_string_ostream os(result.tu_index_data); - tu_index.serialize(os); - } - return result; - }); - - doc->strand.unlock(); - doc->ast_ready.set(); - shrink_if_over_limit(); - - co_return compile_result.value(); + doc->unit = compile(cp); + doc->has_ast = true; + doc->dirty.store(false, std::memory_order_release); + + worker::CompileResult result; + result.version = doc->version; + if(doc->unit.completed() || doc->unit.fatal_error()) { + auto diags = feature::diagnostics(doc->unit); + auto json = kota::codec::json::to_json(diags); + result.diagnostics = kota::codec::RawValue{json ? std::move(*json) : "[]"}; + LOG_INFO("Compile done: path={}, {}ms, {} diags, fatal={}", + params.path, + timer.ms(), + diags.size(), + doc->unit.fatal_error()); + } else { + result.diagnostics = kota::codec::RawValue{"[]"}; + LOG_WARN("Compile incomplete: path={}, {}ms", params.path, timer.ms()); + } + result.memory_usage = 0; // TODO: query actual memory + if(doc->unit.completed()) { + result.inactive_regions = + feature::inactive_regions(doc->unit, params.open_conditionals, doc->pch.second) + .regions; + result.deps = doc->unit.deps(); + + // Build index for main file only (interested_only=true). + auto tu_index = index::TUIndex::build(doc->unit, true); + llvm::raw_string_ostream os(result.tu_index_data); + tu_index.serialize(os); + } + return result; }); + doc->strand.unlock(); + doc->ast_ready.set(); + shrink_if_over_limit(); + + co_return compile_result.value(); + }); + // === DocumentUpdate === // Only mark the document dirty — do NOT update doc.text or doc.version // here. The kota::queue compilation work may be reading doc.text on the diff --git a/src/server/worker/stateless_worker.cpp b/src/server/worker/stateless_worker.cpp index dd7ed35afa..6b474971fe 100644 --- a/src/server/worker/stateless_worker.cpp +++ b/src/server/worker/stateless_worker.cpp @@ -4,6 +4,7 @@ #include "compile/compilation.h" #include "feature/feature.h" +#include "feature/inactive_regions.h" #include "index/tu_index.h" #include "server/protocol/worker.h" #include "server/worker/worker_common.h" @@ -95,11 +96,13 @@ static worker::BuildResult handle_build_pch(const worker::BuildParams& params) { std::string tu_index_data; std::string pch_links_json; + feature::InactiveScan inactive; if(success) { tu_index_data = serialize_tu_index(unit); auto links = feature::document_links(unit); auto raw = to_raw(links); pch_links_json = std::move(raw.data); + inactive = feature::inactive_regions(unit, {}, 0, params.preamble_bound); } // Destroy CompilationUnit to flush PCH to disk. @@ -113,6 +116,8 @@ static worker::BuildResult handle_build_pch(const worker::BuildParams& params) { result.deps = pch_info.deps; result.tu_index_data = std::move(tu_index_data); result.pch_links_json = std::move(pch_links_json); + result.inactive_regions = std::move(inactive.regions); + result.open_conditionals = std::move(inactive.open_stack); return result; } else { LOG_WARN("BuildPCH failed: file={}, {}ms, errors=[{}]", params.file, timer.ms(), errors); diff --git a/src/server/worker/worker_pool.cpp b/src/server/worker/worker_pool.cpp index 4a926e871c..a40d84400d 100644 --- a/src/server/worker/worker_pool.cpp +++ b/src/server/worker/worker_pool.cpp @@ -321,7 +321,18 @@ bool WorkerPool::process_crash(std::size_t index, bool stateful, int exit_code, auto& w = workers[index]; w.alive = false; - if(exit_signal != 0) { + // POSIX SIGHUP == 1 by value: Windows' does not define the + // macro, and a worker can only receive it on POSIX anyway. + constexpr int sighup = 1; + if(exit_signal == SIGTERM || exit_signal == SIGINT || exit_signal == sighup) { + // Termination requested from outside — e.g. the editor tearing the + // whole process group down on a hard restart. The worker did not + // crash; don't alarm the user through the anomaly channel. + LOG_WARN("Worker {} terminated by signal {} (restarts: {})", + w.name, + exit_signal, + w.restart_count); + } else if(exit_signal != 0) { LOG_ANOMALY(WorkerCrash, "Worker {} killed by signal {} (restarts: {})", w.name, diff --git a/src/server/workspace/workspace.cpp b/src/server/workspace/workspace.cpp index f4deb7accc..4fb58a667b 100644 --- a/src/server/workspace/workspace.cpp +++ b/src/server/workspace/workspace.cpp @@ -1,9 +1,15 @@ #include "server/workspace/workspace.h" +#include #include +#include +#include +#include "command/search_config.h" #include "support/filesystem.h" #include "support/logging.h" +#include "syntax/include_resolver.h" +#include "syntax/preamble_synthesis.h" #include "syntax/scan.h" #include "kota/codec/json/json.h" @@ -16,7 +22,151 @@ namespace clice { +bool Workspace::is_synthesized_artifact(llvm::StringRef path) const { + if(config.project.cache_dir.empty()) { + return false; + } + auto artifact_dir = path::join(config.project.cache_dir, "header_context"); + return path.starts_with(artifact_dir); +} + +HeaderMode Workspace::header_mode(llvm::StringRef path, std::uint32_t path_id) const { + // Keep in sync with the client's C++ fragment detection + // (editors/vscode/src/feature/context.ts). + if(path.ends_with(".def") || path.ends_with(".inc") || path.ends_with(".inl") || + path.ends_with(".tpp") || path.ends_with(".ipp")) { + return HeaderMode::NeedsContext; + } + if(auto it = header_modes.find(path_id); it != header_modes.end()) { + return it->second; + } + return HeaderMode::Unknown; +} + +std::uint32_t Workspace::count_occurrences(std::uint32_t host_id, std::uint32_t target_id) const { + auto chain = dep_graph.find_include_chain(host_id, target_id); + if(chain.size() < 2) { + return 0; + } + auto includer_path = path_pool.resolve(chain[chain.size() - 2]); + auto target_path = path_pool.resolve(target_id); + auto buf = llvm::MemoryBuffer::getFile(includer_path); + if(!buf) { + return 0; + } + auto null_resolver = + [](llvm::StringRef, bool, bool, llvm::StringRef) -> std::optional { + return std::nullopt; + }; + return count_include_occurrences((*buf)->getBuffer(), + includer_path, + target_path, + null_resolver); +} + +void Workspace::forget_self_contained(std::uint32_t path_id) { + if(auto it = header_modes.find(path_id); + it != header_modes.end() && it->second == HeaderMode::SelfContained) { + header_modes.erase(it); + } +} + +llvm::SmallVector Workspace::rank_hosts(std::uint32_t header_path_id, + llvm::ArrayRef hosts) const { + auto header_path = path_pool.resolve(header_path_id); + auto header_stem = llvm::sys::path::stem(header_path); + auto header_dir = llvm::sys::path::parent_path(header_path); + + auto score = [&](std::uint32_t host_id) -> std::tuple { + auto host_path = path_pool.resolve(host_id); + int stem_match = llvm::sys::path::stem(host_path) == header_stem ? 0 : 1; + int same_dir = llvm::sys::path::parent_path(host_path) == header_dir ? 0 : 1; + // Longer shared prefix means "closer" in the tree; negate for + // ascending sort. + std::size_t common = 0; + auto n = std::min(host_path.size(), header_path.size()); + while(common < n && host_path[common] == header_path[common]) { + ++common; + } + return {stem_match, same_dir, n - common}; + }; + + llvm::SmallVector ranked(hosts.begin(), hosts.end()); + std::ranges::sort(ranked, [&](std::uint32_t a, std::uint32_t b) { + auto sa = score(a), sb = score(b); + if(sa != sb) { + return sa < sb; + } + return path_pool.resolve(a) < path_pool.resolve(b); + }); + return ranked; +} + +void Workspace::rescan_includes(std::uint32_t path_id) { + auto path = path_pool.resolve(path_id); + dep_graph.clear_includes(path_id); + + if(auto buf = llvm::MemoryBuffer::getFile(path)) { + // Search paths come from the file's own command, or a host's for + // headers without a CDB entry; the synthesized default still + // resolves quote includes via the includer directory. + llvm::StringRef cmd_path = path; + if(!cdb.has_entry(path)) { + for(auto host: rank_hosts(path_id, dep_graph.find_host_sources(path_id))) { + auto host_path = path_pool.resolve(host); + if(cdb.has_entry(host_path)) { + cmd_path = host_path; + break; + } + } + } + + std::vector rule_append, rule_remove; + config.match_rules(cmd_path, rule_append, rule_remove); + auto cmds = cdb.lookup(cmd_path, {.remove = rule_remove, .append = rule_append}); + + // Resolve under every configuration: an include may only be + // reachable through the -I set of a non-first CDB entry. The local + // index serves as config id — prior keys were just cleared and + // consumers read the union. + auto includes = scan((*buf)->getBuffer()).includes; + DirListingCache dir_cache; + auto dir = llvm::sys::path::parent_path(path); + for(std::uint32_t ci = 0; ci < cmds.size(); ++ci) { + auto& cmd = cmds[ci]; + toolchain.resolve_or_warn(cmd); + auto argv = cmd.to_argv(); + auto search_config = extract_search_config(argv, cmd.resolved.directory); + auto resolved_config = resolve_search_config(search_config, dir_cache); + auto entries = resolve_dir(dir, dir_cache); + + llvm::SmallVector ids; + for(auto& include: includes) { + auto resolved = resolve_include(include.path, + include.is_angled, + entries, + dir, + include.is_include_next, + 0, + resolved_config, + dir_cache); + if(resolved) { + ids.push_back(path_pool.intern(resolved->path)); + } + } + dep_graph.set_includes(path_id, ci, std::move(ids)); + } + } + + dep_graph.build_reverse_map(); +} + llvm::SmallVector Workspace::on_file_saved(std::uint32_t path_id) { + // Contexts must see includes added/removed by this save. + rescan_includes(path_id); + + context_epoch += 1; + llvm::SmallVector dirtied; // Re-scan the saved file for module declarations and update path_to_module. @@ -45,7 +195,8 @@ void Workspace::on_file_closed(std::uint32_t path_id) { if(compile_graph && compile_graph->has_unit(path_id)) { compile_graph->update(path_id); } - pch_cache.erase(path_id); + // PCH entries are content-keyed and may be shared with other sessions; + // blob eviction is the CacheStore's job, so nothing to clean up here. } std::uint64_t hash_file(llvm::StringRef path) { @@ -101,11 +252,14 @@ struct CacheDepEntry { }; struct CachePCHEntry { - std::string key; // CacheStore key in the "pch" namespace - std::uint32_t source_file; // index into CacheData::paths + std::string key; // CacheStore key in the "pch" namespace std::uint32_t bound; std::int64_t build_at; std::vector deps; + + // Preamble share of the inactive-region scan; consumed on PCH reuse. + std::vector inactive_regions; + std::vector open_conditionals; }; struct CachePCMEntry { @@ -116,10 +270,31 @@ struct CachePCMEntry { std::vector deps; }; +struct CacheModeEntry { + std::uint32_t file; // index into CacheData::paths + std::uint32_t mode; // HeaderMode + std::uint64_t content_hash; // header contents the verdict was scored on +}; + +struct CacheContextEntry { + std::uint32_t file; // index into CacheData::paths + std::uint32_t host; // index into CacheData::paths; ~0u = none + std::uint32_t occurrence; + std::string command_hash; +}; + +struct CacheArtifactEntry { + std::uint32_t file; // index into CacheData::paths + std::uint32_t host; // index into CacheData::paths +}; + struct CacheData { std::vector paths; std::vector pch; std::vector pcm; + std::vector header_modes; + std::vector contexts; + std::vector artifacts; }; } // namespace @@ -161,18 +336,17 @@ void Workspace::load_cache() { for(auto& entry: data.pch) { auto pch_path = store->lookup("pch", entry.key); - auto source = resolve(entry.source_file); - if(!pch_path || source.empty()) + if(!pch_path) continue; - auto path_id = path_pool.intern(source); - auto& st = pch_cache[path_id]; + auto& st = pch_cache[entry.key]; st.path = *pch_path; - st.key = entry.key; st.bound = entry.bound; st.deps = load_deps(entry.build_at, entry.deps); + st.inactive_regions = entry.inactive_regions; + st.open_conditionals = entry.open_conditionals; - LOG_DEBUG("Loaded cached PCH: {} -> {}", source, *pch_path); + LOG_DEBUG("Loaded cached PCH: {} -> {}", entry.key, *pch_path); } for(auto& entry: data.pcm) { @@ -188,9 +362,49 @@ void Workspace::load_cache() { LOG_DEBUG("Loaded cached PCM: {} (module {}) -> {}", source, entry.module_name, *pcm_path); } - LOG_INFO("Loaded cache.json: {} PCH entries, {} PCM entries", + for(auto& entry: data.header_modes) { + auto file = resolve(entry.file); + if(file.empty() || static_cast(entry.mode) != HeaderMode::NeedsContext) + continue; + // The verdict is tied to the header's contents — a file edited + // while the server was down must re-earn its trial. + if(entry.content_hash != 0 && hash_file(file) != entry.content_hash) + continue; + auto id = path_pool.intern(file); + header_modes[id] = HeaderMode::NeedsContext; + header_mode_hashes[id] = entry.content_hash; + } + + for(auto& entry: data.contexts) { + auto file = resolve(entry.file); + if(file.empty()) + continue; + SavedContext saved; + if(entry.host != ~0u) { + auto host = resolve(entry.host); + if(host.empty()) + continue; + saved.host_path_id = path_pool.intern(host); + } + if(entry.occurrence != ~0u) { + saved.occurrence = entry.occurrence; + } + saved.command_hash = entry.command_hash; + saved_contexts[path_pool.intern(file)] = std::move(saved); + } + + for(auto& entry: data.artifacts) { + auto file = resolve(entry.file); + auto host = resolve(entry.host); + if(file.empty() || host.empty()) + continue; + synthesized_hosts[file] = path_pool.intern(host); + } + + LOG_INFO("Loaded cache.json: {} PCH entries, {} PCM entries, {} context choices", pch_cache.size(), - pcm_cache.size()); + pcm_cache.size(), + saved_contexts.size()); } void Workspace::save_cache() { @@ -210,15 +424,17 @@ void Workspace::save_cache() { return it->second; }; - for(auto& [path_id, st]: pch_cache) { + for(auto& e: pch_cache) { + auto& st = e.second; if(st.path.empty()) continue; CachePCHEntry entry; - entry.key = st.key; - entry.source_file = intern(path_id); + entry.key = e.getKey().str(); entry.bound = st.bound; entry.build_at = st.deps.build_at; + entry.inactive_regions = st.inactive_regions; + entry.open_conditionals = st.open_conditionals; for(std::size_t i = 0; i < st.deps.path_ids.size(); ++i) { entry.deps.push_back({intern(st.deps.path_ids[i]), st.deps.hashes[i]}); } @@ -241,6 +457,33 @@ void Workspace::save_cache() { data.pcm.push_back(std::move(entry)); } + for(auto& [path_id, mode]: header_modes) { + if(mode != HeaderMode::NeedsContext) + continue; + auto hash_it = header_mode_hashes.find(path_id); + data.header_modes.push_back({intern(path_id), + static_cast(mode), + hash_it != header_mode_hashes.end() ? hash_it->second : 0}); + } + + for(auto& entry: synthesized_hosts) { + auto [it, inserted] = index_map.try_emplace(entry.getKey().str(), + static_cast(data.paths.size())); + if(inserted) { + data.paths.push_back(entry.getKey().str()); + } + data.artifacts.push_back({it->second, intern(entry.second)}); + } + + for(auto& [path_id, saved]: saved_contexts) { + CacheContextEntry entry; + entry.file = intern(path_id); + entry.host = saved.host_path_id != no_path_id ? intern(saved.host_path_id) : ~0u; + entry.occurrence = saved.occurrence.value_or(~0u); + entry.command_hash = saved.command_hash; + data.contexts.push_back(std::move(entry)); + } + auto json_str = kota::codec::json::to_json(data); if(!json_str) { LOG_WARN("Failed to serialize cache.json"); diff --git a/src/server/workspace/workspace.h b/src/server/workspace/workspace.h index 4f523b4550..c7eefb2305 100644 --- a/src/server/workspace/workspace.h +++ b/src/server/workspace/workspace.h @@ -22,13 +22,14 @@ #include "llvm/ADT/ArrayRef.h" #include "llvm/ADT/DenseMap.h" #include "llvm/ADT/SmallVector.h" +#include "llvm/ADT/StringMap.h" #include "llvm/ADT/StringRef.h" namespace clice { /// On-disk cache layout version (CacheStore root `cache/v{N}`). /// Bump to discard all cached artifacts after incompatible format changes. -constexpr inline std::uint32_t cache_format_version = 1; +constexpr inline std::uint32_t cache_format_version = 2; /// Two-layer staleness snapshot for compilation artifacts (PCH, AST, etc.). /// @@ -44,22 +45,73 @@ struct DepsSnapshot { std::int64_t build_at = 0; }; +/// Sentinel for "no path": path pool ids start at 0, so 0 is a real file. +constexpr inline std::uint32_t no_path_id = ~0u; + /// Context for compiling a header file that lacks its own CDB entry. -struct HeaderFileContext { - std::uint32_t host_path_id; ///< Source file acting as host. - std::string preamble_path; ///< Path to generated preamble file on disk. - std::uint64_t preamble_hash; ///< Hash of preamble content for staleness. +struct HeaderContext { + std::uint32_t host_path_id = no_path_id; ///< Source file acting as host. + std::string preamble_path; ///< Path to generated preamble file on disk. + std::uint64_t preamble_hash; ///< Hash of preamble content for staleness. + + /// Path to the generated suffix file (content after the include + /// position along the chain), appended to the header's buffer as one + /// trailing #include line. Empty when the suffix is empty. + std::string suffix_path; + + /// Which include of this header in its direct includer produced the + /// preamble (0-based, in directive order). + std::uint32_t occurrence = 0; + + /// Canonical hash of the host CDB entry used (multi-configuration + /// hosts); empty = the first entry. + std::string host_command_hash; + + /// Include chain from host to the target's direct includer (excludes the + /// target itself). The synthesized preamble embeds these files' content, + /// so clang never opens them — staleness must be tracked here. + llvm::SmallVector chain; + + /// Staleness snapshot over the chain files (mtime + content hash). + DepsSnapshot deps; }; -/// Cached PCH state. Content-addressed by preamble text + frontend compile -/// flags — shared across all files (open or on-disk) with the same key. +/// Whether a header can compile on its own (given a borrowed command) +/// or needs a synthesized prefix restoring the includer's preprocessor +/// state. Determined by compiling self-contained first and falling back +/// when the diagnostics indicate missing context. +enum class HeaderMode : std::uint32_t { + Unknown = 0, + SelfContained = 1, + NeedsContext = 2, +}; + +/// A user's context choice, persisted across sessions. +struct SavedContext { + /// Header context host; no_path_id = none. + std::uint32_t host_path_id = no_path_id; + + /// Pinned include occurrence; no value = automatic. + std::optional occurrence; + + std::string command_hash; ///< Pinned CDB entry; empty = none. +}; + +/// Cached PCH state. Stored in Workspace.pch_cache keyed by the content +/// key (hex of xxh3_128bits over preamble text + directories + canonical +/// flags), so files with identical preambles share one PCH. struct PCHState { std::string path; std::uint32_t bound = 0; - /// CacheStore key: hex of xxh3_128bits(preamble text + canonical flags). - std::string key; DepsSnapshot deps; std::string document_links_json; ///< Pre-serialized DocumentLink[] from PCH build + + /// Inactive regions within the preamble (flat offset pairs) and the + /// conditional stack still open at the bound — a #if cut by the bound + /// resumes in the AST compile's scan. + std::vector inactive_regions; + std::vector open_conditionals; + std::shared_ptr building; }; @@ -117,10 +169,10 @@ struct Workspace { /// declarations change. llvm::DenseMap path_to_module; - /// PCH cache, keyed by file path_id. Hot-path mirror of CacheStore - /// state; blob paths come from the store. - /// TODO: re-key by content hash to enable cross-file sharing. - llvm::DenseMap pch_cache; + /// PCH cache, keyed by content key (preamble text + canonical flags), + /// so files with identical preambles share one PCH. Hot-path mirror + /// of CacheStore state; blob paths come from the store. + llvm::StringMap pch_cache; /// PCM cache, keyed by module source path_id. llvm::DenseMap pcm_cache; @@ -137,6 +189,68 @@ struct Workspace { /// for position mapping. llvm::DenseMap merged_indices; + /// Monotonic generation of context-affecting workspace state (include + /// graph, CDB, disk contents). Bumped on didSave; clice/queryContext + /// stamps its results with it and clice/switchContext rejects requests + /// made against an older epoch, so a client can never apply a context + /// picked from a stale listing without noticing. + std::uint64_t context_epoch = 1; + + /// Self-containment verdicts for headers, persisted in cache.json. + /// Reset when the header itself is saved. + llvm::DenseMap header_modes; + + /// Content hash of the header at the time its NeedsContext verdict was + /// scored — persisted so a stale verdict is dropped on cache load. + llvm::DenseMap header_mode_hashes; + + /// User context choices (clice/switchContext), persisted in cache.json + /// and restored into the Session on didOpen. + llvm::DenseMap saved_contexts; + + /// Host source of each synthesized artifact (prefix/suffix/snapshot + /// file path -> host path_id), recorded at synthesis time and + /// persisted in cache.json. Opening an artifact compiles it with its + /// host's command — it is a fragment of that TU, and treated as + /// self-contained (an artifact needing context itself is out of scope). + llvm::StringMap synthesized_hosts; + + /// Whether `path` is one of our own synthesized context artifacts + /// (prefix/suffix/self-snapshot files under the cache directory). A + /// user can open these for debugging; they must never go through + /// header-context resolution themselves — a synthesized file deriving + /// context from other synthesized files would chain junk state. + bool is_synthesized_artifact(llvm::StringRef path) const; + + /// Effective self-containment mode for a header. X-macro style + /// extensions are non-self-contained by construction; otherwise use + /// the persisted verdict. Only NeedsContext is ever persisted — a + /// "self-contained" impression is session-local and re-evaluated when + /// compile inputs change, so it can never go stale. + HeaderMode header_mode(llvm::StringRef path, std::uint32_t path_id) const; + + /// Drop an in-memory SelfContained verdict (never a persisted + /// NeedsContext) so the next compile re-runs the trial. + void forget_self_contained(std::uint32_t path_id); + + /// How many times the direct includer on host->target's chain includes + /// the target. Spelling-based (no search-path resolution): multiple + /// inclusions of one header always share a spelling, and synthesis + /// validates the real occurrence anyway. + std::uint32_t count_occurrences(std::uint32_t host_id, std::uint32_t target_id) const; + + /// Rank host source candidates for a header by relevance: a source + /// with the header's stem (utils.h -> utils.cpp) wins, then sources in + /// the same directory, then longer common path prefixes; ties break + /// lexicographically so the choice is deterministic. + llvm::SmallVector rank_hosts(std::uint32_t header_path_id, + llvm::ArrayRef hosts) const; + + /// Re-resolve a saved file's direct include edges from its disk + /// content, so host lookups and context queries see includes the save + /// added or removed. + void rescan_includes(std::uint32_t path_id); + /// Called when a file is saved to disk. Cascades invalidation through /// compile_graph and clears affected PCM caches. /// Returns path_ids of all files dirtied by the cascade. diff --git a/src/support/cache_store.cpp b/src/support/cache_store.cpp index 26ff45af3d..6704d2834e 100644 --- a/src/support/cache_store.cpp +++ b/src/support/cache_store.cpp @@ -362,6 +362,13 @@ CacheStore::PendingEntry CacheStore::begin_store(llvm::StringRef ns, llvm::Strin return {}; } + // The cache directory can be wiped externally while the server runs + // (a user resetting state with `rm -rf`); re-create the tmp dir so + // writers don't fail forever afterwards. Idempotent and cheap. + if(auto ec = llvm::sys::fs::create_directories(state->tmp_dir)) { + LOG_WARN("CacheStore: cannot re-create tmp dir {}: {}", state->tmp_dir, ec.message()); + } + auto tmp_name = std::format("{}{}", state->next_tmp_id++, ns_state->config.extension); return PendingEntry{ns.str(), key.str(), path::join(state->tmp_dir, tmp_name)}; } @@ -406,6 +413,11 @@ std::expected CacheStore::commit(PendingEntry pend } final_path = state->blob_path(*ns_state, pending.key); + // The namespace dir can be wiped externally while the server runs; + // re-create it so the rename below doesn't fail forever. + if(auto ec = llvm::sys::fs::create_directories(ns_state->dir)) { + LOG_WARN("CacheStore: cannot re-create dir {}: {}", ns_state->dir, ec.message()); + } if(auto result = fs::rename(pending.tmp_path, final_path); !result) { if(same_content(pending.tmp_path, final_path)) { // Benign collision: an identical blob is already published diff --git a/src/syntax/dependency_graph.cpp b/src/syntax/dependency_graph.cpp index 8e7ce987c8..5ace374dcb 100644 --- a/src/syntax/dependency_graph.cpp +++ b/src/syntax/dependency_graph.cpp @@ -99,6 +99,19 @@ std::size_t DependencyGraph::edge_count() const { return count; } +void DependencyGraph::clear_includes(std::uint32_t path_id) { + llvm::SmallVector stale; + for(auto& [key, ids]: includes) { + if(key.path_id == path_id) { + stale.push_back(key); + } + } + for(auto& key: stale) { + includes.erase(key); + } + file_configs.erase(path_id); +} + void DependencyGraph::build_reverse_map() { reverse_includes_.clear(); for(auto& [key, ids]: includes) { diff --git a/src/syntax/dependency_graph.h b/src/syntax/dependency_graph.h index ae7d8bfc22..08580bbfbf 100644 --- a/src/syntax/dependency_graph.h +++ b/src/syntax/dependency_graph.h @@ -73,6 +73,10 @@ class DependencyGraph { /// Get the union of includes across all configs for a file. llvm::SmallVector get_all_includes(std::uint32_t path_id) const; + /// Erase every config's include list for a file. Incremental didSave + /// rescans clear first, then re-add one list per configuration. + void clear_includes(std::uint32_t path_id); + /// Build the reverse include map from the forward includes. /// Must be called after all set_includes() calls are complete. void build_reverse_map(); diff --git a/src/syntax/preamble_synthesis.cpp b/src/syntax/preamble_synthesis.cpp new file mode 100644 index 0000000000..dddcfac08c --- /dev/null +++ b/src/syntax/preamble_synthesis.cpp @@ -0,0 +1,283 @@ +#include "syntax/preamble_synthesis.h" + +#include "syntax/scan.h" + +#include "llvm/ADT/SmallVector.h" +#include "llvm/Support/Path.h" + +namespace clice { + +/// Emit a #line marker resetting location to line 1 of `path`. +/// Backslashes and quotes are escaped so Windows paths survive the +/// round-trip through the preprocessor's string literal parsing. +static void append_line_marker(std::string& out, llvm::StringRef path) { + out += R"(#line 1 ")"; + for(char c: path) { + if(c == '\\' || c == '"') { + out += '\\'; + } + out += c; + } + out += "\"\n"; +} + +/// Emit `path` as a quoted include/marker operand, escaping backslashes +/// and quotes so Windows paths survive string-literal parsing. +static void append_quoted_path(std::string& out, llvm::StringRef path) { + out += '"'; + for(char c: path) { + if(c == '\\' || c == '"') { + out += '\\'; + } + out += c; + } + out += '"'; +} + +/// Collect all directives in `includes` that bring in `next_path`, in +/// directive order. Prefers exact resolved-path matches; when resolution +/// found nothing, falls back to filename matches — but only if all +/// fallback candidates share one raw spelling (distinct spellings could +/// name different files, so refuse to guess between them). +static llvm::SmallVector + collect_candidates(llvm::ArrayRef includes, + llvm::ArrayRef> resolved, + llvm::StringRef next_path) { + llvm::SmallVector candidates; + for(std::size_t j = 0; j < includes.size(); ++j) { + if(resolved[j].has_value() && *resolved[j] == next_path) { + candidates.push_back(j); + } + } + if(!candidates.empty()) { + return candidates; + } + + auto next_filename = llvm::sys::path::filename(next_path); + for(std::size_t j = 0; j < includes.size(); ++j) { + if(llvm::sys::path::filename(includes[j].path) != next_filename) { + continue; + } + if(!candidates.empty() && includes[candidates.front()].path != includes[j].path) { + return {}; + } + candidates.push_back(j); + } + return candidates; +} + +/// Pick the directive to cut at. An explicit occurrence indexes the +/// candidate list directly; otherwise unconditional directives win over +/// ones inside #if blocks, so an include occurrence in an untaken branch +/// does not shadow the real one. +static std::optional find_match(llvm::ArrayRef includes, + llvm::ArrayRef> resolved, + llvm::StringRef next_path, + std::optional occurrence) { + auto candidates = collect_candidates(includes, resolved, next_path); + if(candidates.empty()) { + return std::nullopt; + } + if(occurrence.has_value()) { + if(*occurrence >= candidates.size()) { + return std::nullopt; + } + return candidates[*occurrence]; + } + for(auto j: candidates) { + if(!includes[j].conditional) { + return j; + } + } + return candidates.front(); +} + +/// Append a #line marker for line `line` (1-based) of `path`. +static void append_line_marker_at(std::string& out, llvm::StringRef path, std::uint32_t line) { + out += "#line "; + out += std::to_string(line); + out += " \""; + for(char c: path) { + if(c == '\\' || c == '"') { + out += '\\'; + } + out += c; + } + out += "\"\n"; +} + +/// Emit content[from, to) rewriting resolved quoted includes to absolute +/// paths: the synthesized files live in the cache directory, so +/// includer-relative lookup would resolve against the wrong location. +/// Angled includes never use the includer's directory and keep their +/// system-header semantics; #include_next is kept verbatim (its +/// search-resume semantics cannot survive relocation anyway). +static void emit_rewritten(std::string& out, + llvm::StringRef content, + std::uint32_t from, + std::uint32_t to, + llvm::ArrayRef includes, + llvm::ArrayRef> resolved, + llvm::StringRef target_path, + llvm::StringRef self_snapshot_path) { + std::uint32_t pos = from; + for(std::size_t j = 0; j < includes.size(); ++j) { + auto& include = includes[j]; + if(include.name_offset < from || include.offset >= to) { + continue; + } + if(!resolved[j].has_value()) { + continue; + } + + // Another include of the target itself (a different occurrence): + // redirect it to the disk snapshot, or blank the directive line + // (keeping the line count) when no snapshot is available. + if(*resolved[j] == target_path) { + if(!self_snapshot_path.empty()) { + out += content.substr(pos, include.name_offset - pos); + append_quoted_path(out, self_snapshot_path); + pos = include.name_offset + include.name_length; + continue; + } + auto line_start = content.rfind('\n', include.offset); + auto begin = line_start == llvm::StringRef::npos + ? from + : std::max(from, static_cast(line_start + 1)); + auto eol = content.find('\n', include.offset); + auto end = + eol == llvm::StringRef::npos ? to : std::min(to, static_cast(eol)); + out += content.substr(pos, begin - pos); + pos = end; + continue; + } + + if(include.is_angled || include.is_include_next) { + continue; + } + out += content.substr(pos, include.name_offset - pos); + append_quoted_path(out, *resolved[j]); + pos = include.name_offset + include.name_length; + } + out += content.substr(pos, to - pos); + if(!out.ends_with('\n')) { + out += '\n'; + } +} + +std::optional synthesize_context(llvm::ArrayRef chain, + llvm::StringRef target_path, + IncludeResolver resolve, + std::optional occurrence, + llvm::StringRef self_snapshot_path) { + SynthesizedContext out; + llvm::SmallVector suffix_fragments; + + for(std::size_t i = 0; i < chain.size(); ++i) { + auto& entry = chain[i]; + bool is_last = i + 1 == chain.size(); + auto next_path = is_last ? target_path : chain[i + 1].path; + auto includer_dir = llvm::sys::path::parent_path(entry.path); + + auto scan_result = scan(entry.content); + + llvm::SmallVector> resolved; + resolved.reserve(scan_result.includes.size()); + for(auto& include: scan_result.includes) { + resolved.push_back( + resolve(include.path, include.is_angled, include.is_include_next, includer_dir)); + } + + // The occurrence choice applies to the direct includer only. + auto match = find_match(scan_result.includes, + resolved, + next_path, + is_last ? occurrence : std::nullopt); + if(!match) { + return std::nullopt; + } + + auto& matched = scan_result.includes[*match]; + auto cut = matched.offset; + auto depth = matched.conditional_depth; + + // Prefix: everything before the matched directive, then balancing + // #endifs when the cut lands inside #if blocks (most commonly an + // include guard on an intermediate header). The guard condition is + // still evaluated by the compiler, so the fragment's semantics hold. + append_line_marker(out.prefix, entry.path); + emit_rewritten(out.prefix, + entry.content, + 0, + cut, + scan_result.includes, + resolved, + target_path, + self_snapshot_path); + for(std::uint16_t d = depth; d > 0; --d) { + out.prefix += "#endif\n"; + } + + // Suffix: everything after the matched directive's line, mirrored + // (assembled innermost-first below). The prefix closed `depth` + // conditionals early, so reopen them with `#if 1` to keep the + // fragment's own #endifs balanced. + auto line_end = entry.content.find('\n', cut); + auto resume = line_end == llvm::StringRef::npos + ? static_cast(entry.content.size()) + : static_cast(line_end + 1); + std::string fragment; + for(std::uint16_t d = depth; d > 0; --d) { + fragment += "#if 1\n"; + } + auto resume_line = + static_cast(entry.content.substr(0, resume).count('\n')) + 1; + append_line_marker_at(fragment, entry.path, resume_line); + emit_rewritten(fragment, + entry.content, + resume, + entry.content.size(), + scan_result.includes, + resolved, + target_path, + self_snapshot_path); + suffix_fragments.push_back(std::move(fragment)); + } + + for(auto& fragment: llvm::reverse(suffix_fragments)) { + out.suffix += fragment; + } + + return out; +} + +std::optional synthesize_preamble(llvm::ArrayRef chain, + llvm::StringRef target_path, + IncludeResolver resolve, + std::optional occurrence) { + auto context = synthesize_context(chain, target_path, resolve, occurrence); + if(!context) { + return std::nullopt; + } + return std::move(context->prefix); +} + +std::uint32_t count_include_occurrences(llvm::StringRef content, + llvm::StringRef includer_path, + llvm::StringRef target_path, + IncludeResolver resolve) { + auto includer_dir = llvm::sys::path::parent_path(includer_path); + auto scan_result = scan(content); + + llvm::SmallVector> resolved; + resolved.reserve(scan_result.includes.size()); + for(auto& include: scan_result.includes) { + resolved.push_back( + resolve(include.path, include.is_angled, include.is_include_next, includer_dir)); + } + + return static_cast( + collect_candidates(scan_result.includes, resolved, target_path).size()); +} + +} // namespace clice diff --git a/src/syntax/preamble_synthesis.h b/src/syntax/preamble_synthesis.h new file mode 100644 index 0000000000..b996b3bc03 --- /dev/null +++ b/src/syntax/preamble_synthesis.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include + +#include "llvm/ADT/ArrayRef.h" +#include "llvm/ADT/STLFunctionalExtras.h" +#include "llvm/ADT/StringRef.h" + +namespace clice { + +/// One file along an include chain, from the host source file down to the +/// direct includer of the target header. The target itself is not part of +/// the chain — its buffer is the compilation main file. +struct ChainEntry { + /// Absolute path, used for #line markers and include resolution. + llvm::StringRef path; + + /// File content as read from disk. + llvm::StringRef content; +}; + +/// Resolve an include directive to an absolute path. +/// Arguments: raw header name (without delimiters), is_angled, +/// is_include_next, directory of the including file. +/// Returns the resolved absolute path, or nullopt if not found. +using IncludeResolver = + llvm::function_ref(llvm::StringRef, bool, bool, llvm::StringRef)>; + +/// Synthesize the prefix preamble for a header context. +/// +/// For each file in the chain, scans its include directives, finds the one +/// that resolves to the next file in the chain (the target for the last +/// entry), and emits everything before that directive prefixed with a +/// #line marker. The result restores the preprocessor state the target +/// header would see when compiled as part of the host translation unit. +/// +/// Matching prefers exact resolved-path equality. If no directive resolves +/// to the next path (e.g. resolution failed for an exotic search setup), +/// falls back to a filename match — but only if it is unambiguous. +/// Returns nullopt when a chain step cannot be matched. +/// +/// `occurrence` selects among multiple includes of the target in its +/// direct includer (the last chain entry): a file without include guards +/// can be included several times with different preprocessor states, and +/// each occurrence is a distinct context. It indexes the candidate list in +/// directive order (0-based); out of range fails the synthesis. When +/// unset, unconditional candidates are preferred over ones inside #if +/// blocks. +std::optional synthesize_preamble(llvm::ArrayRef chain, + llvm::StringRef target_path, + IncludeResolver resolve, + std::optional occurrence = {}); + +/// Prefix and suffix restoring the full includer context of a header. +struct SynthesizedContext { + /// Everything before the target's include, host-first (see + /// synthesize_preamble). Injected via -include. + std::string prefix; + + /// Everything after the target's include, mirrored: the rest of the + /// direct includer first, then the rest of its includer, up to the + /// host. Each fragment gets a #line marker; a cut inside #if blocks + /// opens matching `#if 1`s so the fragment's own #endifs stay + /// balanced. Injected by appending one #include line to the header's + /// buffer, so X-macro fragments embedded in enums or function bodies + /// see their surrounding braces close. + std::string suffix; +}; + +/// Synthesize both sides of the includer context in one pass. The prefix +/// equals synthesize_preamble's result for the same inputs. +/// `self_snapshot_path`: replacement target for includes of the header +/// itself (other occurrences along the chain). At compile time the +/// target's path is remapped to the open buffer with a trailing suffix +/// include, so keeping such directives verbatim would recurse; they are +/// redirected to a disk snapshot of the header instead, or blanked (line +/// count preserved) when no snapshot is provided. +std::optional synthesize_context(llvm::ArrayRef chain, + llvm::StringRef target_path, + IncludeResolver resolve, + std::optional occurrence = {}, + llvm::StringRef self_snapshot_path = {}); + +/// Count how many include directives in `content` bring in `target_path` +/// (candidates in the sense of synthesize_preamble's matching). +std::uint32_t count_include_occurrences(llvm::StringRef content, + llvm::StringRef includer_path, + llvm::StringRef target_path, + IncludeResolver resolve); + +} // namespace clice diff --git a/src/syntax/scan.cpp b/src/syntax/scan.cpp index 34603b82d7..8ae45a13b7 100644 --- a/src/syntax/scan.cpp +++ b/src/syntax/scan.cpp @@ -68,7 +68,11 @@ ScanResult scan(llvm::StringRef content) { bool angled = name.front() == '<'; ScanResult::IncludeInfo info; info.path = std::string(name.substr(1, name.size() - 2)); + info.offset = dir.Tokens.front().Offset; + info.name_offset = tok.Offset; + info.name_length = tok.Length; info.conditional = conditional_depth > 0; + info.conditional_depth = static_cast(conditional_depth); info.is_angled = angled; info.is_include_next = dir.Kind == dds::pp_include_next; result.includes.push_back(std::move(info)); diff --git a/src/syntax/scan.h b/src/syntax/scan.h index 005afc8514..d53c70d2d0 100644 --- a/src/syntax/scan.h +++ b/src/syntax/scan.h @@ -28,9 +28,22 @@ struct ScanResult { /// Resolved file path (fuzzy/precise scan) or raw header name (lexer scan). std::string path; + /// Byte offset of the directive's `#` in the scanned content. + /// Only populated by the lexer-based scan(); 0 for other scan modes. + std::uint32_t offset = 0; + + /// Range of the filename token including delimiters (`"..."` or + /// `<...>`). Only populated by the lexer-based scan(). + std::uint32_t name_offset = 0; + std::uint32_t name_length = 0; + /// Whether this include is inside a conditional directive context. bool conditional = false; + /// Number of #if/#ifdef/#ifndef blocks open at this directive. + /// Only populated by the lexer-based scan(). + std::uint16_t conditional_depth = 0; + /// Whether the included file was not found during resolution. bool not_found = false; diff --git a/tests/integration/compilation/test_header_pch.py b/tests/integration/compilation/test_header_pch.py new file mode 100644 index 0000000000..ab90189e6b --- /dev/null +++ b/tests/integration/compilation/test_header_pch.py @@ -0,0 +1,90 @@ +"""Integration tests for PCH interaction with header contexts. + +Covers the unified-preamble design: the -include'd synthesized prefix is +baked into the PCH via the predefines buffer, clang's PPOpts validation +subsumes the -include on reuse (no double processing), and a header with +no directives of its own (bound == 0) still gets a PCH. +""" + +from tests.integration.utils import write_cdb +from tests.integration.utils.assertions import assert_clean_compile +from tests.integration.utils.cache import list_pch_files + + +async def test_prefix_not_reprocessed(client, tmp_path): + """A bare definition in the synthesized prefix must not be processed + twice (once in the PCH, once via -include) — that would be an error.""" + (tmp_path / "utils.h").write_text( + "inline int next_id() { return shared_counter + 1; }\n" + ) + (tmp_path / "main.cpp").write_text( + 'int shared_counter = 0;\n#include "utils.h"\nint main() { return next_id(); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + utils_uri, _ = await client.open_and_wait(tmp_path / "utils.h") + assert_clean_compile(client, utils_uri) + + +async def test_def_file_builds_pch(client, tmp_path): + """An X-macro .def file has no directives of its own (bound == 0), but + the header context PCH must still be built to cache the prefix.""" + (tmp_path / "errors.def").write_text("X(ok, 0)\nX(bad, 1)\n") + (tmp_path / "main.cpp").write_text( + "#define X(name, code) inline int handle_##name() { return code; }\n" + '#include "errors.def"\n' + "#undef X\n" + "int main() { return handle_ok(); }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + before = len(list_pch_files(tmp_path)) + + def_uri, _ = await client.open_and_wait(tmp_path / "errors.def") + assert_clean_compile(client, def_uri) + assert len(list_pch_files(tmp_path)) == before + 1, ( + "bound == 0 header context must still build a PCH for the prefix" + ) + + +async def test_bare_header_skips_pch(client, tmp_path): + """A self-contained header with no directives of its own has nothing to + precompile — no PCH must be built for it.""" + (tmp_path / "bare.h").write_text("inline int bare() { return 1; }\n") + (tmp_path / "main.cpp").write_text( + '#include "bare.h"\nint main() { return bare(); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + before = len(list_pch_files(tmp_path)) + + bare_uri, _ = await client.open_and_wait(tmp_path / "bare.h") + assert_clean_compile(client, bare_uri) + assert len(list_pch_files(tmp_path)) == before, ( + "A self-contained header with an empty preamble must not build a PCH" + ) + + +async def test_links_merge_empty_pch(client, tmp_path): + """documentLink must stay valid JSON when the PCH contributes no links + (a preamble of only #defines) but the body has includes.""" + (tmp_path / "list.def").write_text("X(alpha)\n") + (tmp_path / "main.cpp").write_text( + "#define X(name) int name;\n" + "int before = 0;\n" + '#include "list.def"\n' + "#undef X\n" + "int main() { return alpha; }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + main_uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + links = await client.document_links(main_uri) + assert links, "Expected a document link for the body include" diff --git a/tests/integration/compilation/test_persistent_cache.py b/tests/integration/compilation/test_persistent_cache.py index d652e12747..44ec1e7954 100644 --- a/tests/integration/compilation/test_persistent_cache.py +++ b/tests/integration/compilation/test_persistent_cache.py @@ -1,7 +1,7 @@ """Integration tests for persistent PCH/PCM cache. Verifies that PCH/PCM artifacts are written to the unified cache store -(.clice/cache/v1/{pch,pcm}/) with content-addressed filenames, survive +(.clice/cache/v2/{pch,pcm}/) with content-addressed filenames, survive server restarts via cache.json, and are properly reused across sessions. """ @@ -75,7 +75,7 @@ async def test_cache_json_persisted(client, tmp_path): assert "key" in entry assert "build_at" in entry assert "deps" in entry - assert "source_file" in entry + assert "bound" in entry async def test_pch_reused_on_close_reopen(client, tmp_path): @@ -384,3 +384,36 @@ async def test_kill9_recovery(executable, tmp_path): # Clean shutdown removed session 2's own tmp; session 1's residue was # swept at session 2 startup, so nothing may remain. assert list_tmp_files(tmp_path) == [], "tmp residue should be swept" + + +async def test_cache_wiped_while_running(client, tmp_path): + """Wiping the cache directory under a running server must not wedge + PCH builds forever: the store re-creates its directories on demand.""" + import asyncio + import shutil + + pin_cache_to_workspace(tmp_path) + (tmp_path / "header.h").write_text("#pragma once\nstruct W { int x; };\n") + (tmp_path / "main.cpp").write_text( + '#include "header.h"\nint main() { W w; return w.x; }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + assert_clean_compile(client, uri) + + # Simulate a user resetting state without restarting the server. + shutil.rmtree(tmp_path / ".clice" / "cache") + + # Change the preamble so a fresh PCH build is required. + await asyncio.sleep(1.1) + (tmp_path / "header.h").write_text("#pragma once\nstruct W { int x; int y; };\n") + + from tests.integration.utils.wait import wait_for_recompile + + await wait_for_recompile(client, uri) + assert_clean_compile(client, uri) + assert len(list_pch_files(tmp_path)) == 1, ( + "PCH build must recover after a cache wipe" + ) diff --git a/tests/integration/compilation/test_self_containment.py b/tests/integration/compilation/test_self_containment.py new file mode 100644 index 0000000000..9273453b1c --- /dev/null +++ b/tests/integration/compilation/test_self_containment.py @@ -0,0 +1,318 @@ +"""Integration tests for automatic self-containment detection. + +Headers without a CDB entry compile self-contained first (borrowed host +command, no prefix synthesis). When the trial diagnostics indicate missing +includer context, the server falls back to prefix synthesis transparently — +only the final diagnostics are published. Verdicts and user context +choices persist across server sessions via cache.json. +""" + +from tests.conftest import make_client, shutdown_client +from tests.integration.utils import get_field, write_cdb, write_entries +from tests.integration.utils.assertions import assert_clean_compile +from tests.integration.utils.cache import read_cache_json +from tests.integration.utils.wait import wait_for_recompile + + +def prefix_files(workspace): + prefix_dir = workspace / ".clice" / "header_context" + if not prefix_dir.exists(): + return [] + return sorted( + f + for f in prefix_dir.glob("*.h") + if not f.name.endswith((".suffix.h", ".self.h")) + ) + + +async def test_self_contained_skips_synthesis(client, tmp_path): + """A self-contained header borrows a command but gets no prefix.""" + (tmp_path / "types.h").write_text("#pragma once\nstruct Point { int x; int y; };\n") + (tmp_path / "helper.h").write_text( + '#pragma once\n#include "types.h"\ninline int get_x(Point p) { return p.x; }\n' + ) + (tmp_path / "main.cpp").write_text( + '#include "helper.h"\nint main() { return get_x({1, 2}); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + helper_uri, _ = await client.open_and_wait(tmp_path / "helper.h") + assert_clean_compile(client, helper_uri) + assert prefix_files(tmp_path) == [], ( + "Self-contained headers must not synthesize a prefix" + ) + + +async def test_fallback_on_missing_context(client, tmp_path): + """A non-self-contained header falls back to prefix synthesis + automatically; the trial's error diagnostics are never published.""" + (tmp_path / "types.h").write_text("#pragma once\nstruct Point { int x; int y; };\n") + (tmp_path / "utils.h").write_text("inline int get_x(Point p) { return p.x; }\n") + (tmp_path / "main.cpp").write_text( + '#include "types.h"\n#include "utils.h"\nint main() { return get_x({1, 2}); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + utils_uri, _ = await client.open_and_wait(tmp_path / "utils.h") + assert_clean_compile(client, utils_uri) + assert len(prefix_files(tmp_path)) == 1, ( + "Fallback must synthesize exactly one prefix" + ) + + +async def test_verdict_persisted_across_sessions(executable, tmp_path): + """The NeedsContext verdict lands in cache.json and survives restarts.""" + (tmp_path / "types.h").write_text("#pragma once\nstruct Point { int x; int y; };\n") + (tmp_path / "utils.h").write_text("inline int get_x(Point p) { return p.x; }\n") + (tmp_path / "main.cpp").write_text( + '#include "types.h"\n#include "utils.h"\nint main() { return get_x({1, 2}); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + + c1 = await make_client(executable, tmp_path) + await c1.open_and_wait(tmp_path / "main.cpp") + utils_uri, _ = await c1.open_and_wait(tmp_path / "utils.h") + assert_clean_compile(c1, utils_uri) + await shutdown_client(c1) + + cache = read_cache_json(tmp_path) + assert cache is not None and cache.get("header_modes"), ( + f"Expected a persisted header mode, got: {cache and cache.keys()}" + ) + + c2 = await make_client(executable, tmp_path) + await c2.open_and_wait(tmp_path / "main.cpp") + utils_uri2, _ = await c2.open_and_wait(tmp_path / "utils.h") + assert_clean_compile(c2, utils_uri2) + await shutdown_client(c2) + + +async def test_choice_persisted_across_sessions(executable, tmp_path): + """A switchContext choice is restored on didOpen in a later session.""" + (tmp_path / "shared.h").write_text("VALUE_TYPE get_value();\n") + (tmp_path / "a.cpp").write_text( + '#define VALUE_TYPE int\n#include "shared.h"\nint main() { return 0; }\n' + ) + (tmp_path / "b.cpp").write_text( + '#define VALUE_TYPE float\n#include "shared.h"\nfloat f() { return 0; }\n' + ) + write_entries(tmp_path, [("a.cpp", []), ("b.cpp", [])]) + + c1 = await make_client(executable, tmp_path) + await c1.open_and_wait(tmp_path / "a.cpp") + await c1.open_and_wait(tmp_path / "b.cpp") + shared_uri, _ = c1.open(tmp_path / "shared.h") + b_uri = c1.path_to_uri(tmp_path / "b.cpp") + switch = await c1.switch_context(shared_uri, b_uri) + assert get_field(switch, "success") is True + await shutdown_client(c1) + + c2 = await make_client(executable, tmp_path) + shared_uri2, _ = c2.open(tmp_path / "shared.h") + current = await c2.current_context(shared_uri2) + ctx = get_field(current, "context") + assert ctx is not None and "b.cpp" in get_field(ctx, "uri"), ( + f"Persisted context choice should be restored on didOpen, got: {current}" + ) + await shutdown_client(c2) + + +async def test_ordinary_error_no_fallback(client, tmp_path): + """A self-contained header with a benign syntax error must not trigger + prefix synthesis nor persist any verdict.""" + (tmp_path / "typo.h").write_text("inline int broken() { return }\n") # syntax error + (tmp_path / "main.cpp").write_text('#include "typo.h"\nint main() { return 0; }\n') + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + typo_uri, _ = await client.open_and_wait(tmp_path / "typo.h") + diags = client.diagnostics.get(typo_uri, []) + assert diags, "The syntax error must be published" + assert prefix_files(tmp_path) == [], ( + "Ordinary errors must not trigger prefix synthesis" + ) + + +async def test_header_save_resets_verdict(executable, tmp_path): + """Saving the header itself re-evaluates its self-containment: a header + that gains its own include stops using the synthesized prefix.""" + import asyncio + + from lsprotocol.types import ( + DidChangeTextDocumentParams, + DidSaveTextDocumentParams, + TextDocumentContentChangeWholeDocument, + VersionedTextDocumentIdentifier, + ) + + from tests.integration.utils import doc + + (tmp_path / "types.h").write_text("#pragma once\nstruct Point { int x; int y; };\n") + utils_h = tmp_path / "utils.h" + utils_h.write_text("inline int get_x(Point p) { return p.x; }\n") + (tmp_path / "main.cpp").write_text( + '#include "types.h"\n#include "utils.h"\nint main() { return get_x({1, 2}); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + + c = await make_client(executable, tmp_path) + await c.open_and_wait(tmp_path / "main.cpp") + utils_uri, _ = await c.open_and_wait(utils_h) + assert_clean_compile(c, utils_uri) + assert len(prefix_files(tmp_path)) == 1, "Initial verdict: needs context" + + # Make the header self-contained on disk and in the buffer, then save. + await asyncio.sleep(1.1) + new_text = '#include "types.h"\ninline int get_x(Point p) { return p.x; }\n' + utils_h.write_text(new_text) + c.text_document_did_change( + DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier(uri=utils_uri, version=2), + content_changes=[TextDocumentContentChangeWholeDocument(text=new_text)], + ) + ) + c.text_document_did_save(DidSaveTextDocumentParams(text_document=doc(utils_uri))) + + await wait_for_recompile(c, utils_uri) + assert_clean_compile(c, utils_uri) + await shutdown_client(c) + + # After shutdown the persisted verdict must be gone. + cache = read_cache_json(tmp_path) + assert not (cache or {}).get("header_modes"), ( + f"Verdict should be reset after the header was saved, got: {cache}" + ) + + +async def test_dependency_change_retries_trial(client, tmp_path): + """A header judged self-contained must be re-evaluated when one of its + own includes changes: here foo.h stops providing FOO, and only the + includer context (the host's define) can still supply it.""" + import asyncio + + (tmp_path / "foo.h").write_text("#pragma once\n#define FOO 1\n") + (tmp_path / "h.h").write_text( + '#pragma once\n#include "foo.h"\ninline int get() { return FOO; }\n' + ) + (tmp_path / "main.cpp").write_text( + '#define FOO 2\n#include "h.h"\nint main() { return get(); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + h_uri, _ = await client.open_and_wait(tmp_path / "h.h") + assert_clean_compile(client, h_uri) + assert prefix_files(tmp_path) == [], "Initially self-contained" + + # foo.h stops defining FOO; only the host's #define can provide it now. + await asyncio.sleep(1.1) + (tmp_path / "foo.h").write_text("#pragma once\n") + + await wait_for_recompile(client, h_uri) + assert_clean_compile(client, h_uri) + assert len(prefix_files(tmp_path)) == 1, ( + "Dependency change must re-run the trial and fall back to synthesis" + ) + + +async def test_suffix_closes_embedding(client, tmp_path): + """X-macro fragments embedded in an enum or a function body compile + cleanly: the synthesized suffix closes the surrounding braces.""" + (tmp_path / "errors.def").write_text( + 'X(Ok, 0, "success")\nX(NotFound, 1, "not found")\n' + ) + (tmp_path / "main.cpp").write_text( + "#define X(name, code, msg) name = code,\n" + "enum ErrorCode {\n" + '#include "errors.def"\n' + "};\n" + "#undef X\n" + "int main() { return Ok; }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + def_uri, _ = await client.open_and_wait(tmp_path / "errors.def") + assert_clean_compile(client, def_uri) + + +async def test_suffix_function_body(client, tmp_path): + """The doc's classic register_all() case: statements expanded inside a + function body, closing brace restored by the suffix.""" + (tmp_path / "handlers.def").write_text("X(alpha)\nX(beta)\n") + (tmp_path / "main.cpp").write_text( + "inline void handle(int) {}\n" + "enum Ids { alpha, beta };\n" + "void register_all() {\n" + "#define X(name) handle(name);\n" + '#include "handlers.def"\n' + "#undef X\n" + "}\n" + "int main() { register_all(); return 0; }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + def_uri, _ = await client.open_and_wait(tmp_path / "handlers.def") + assert_clean_compile(client, def_uri) + + +async def test_open_synthesized_artifact(client, tmp_path): + """Opening a synthesized prefix file compiles it with its host's + command (it is a fragment of that TU), not with junk context.""" + (tmp_path / "types.h").write_text("#pragma once\nstruct Point { int x; int y; };\n") + (tmp_path / "utils.h").write_text("inline int get_x(Point p) { return p.x; }\n") + (tmp_path / "main.cpp").write_text( + '#include "types.h"\n#include "utils.h"\nint main() { return get_x({1, 2}); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + utils_uri, _ = await client.open_and_wait(tmp_path / "utils.h") + assert_clean_compile(client, utils_uri) + + prefixes = prefix_files(tmp_path) + assert len(prefixes) == 1 + prefix_uri, _ = await client.open_and_wait(prefixes[0]) + assert_clean_compile(client, prefix_uri) + + # No context of its own, and no further synthesis chained off it. + q = await client.query_context(prefix_uri) + assert get_field(q, "total") == 0, q + assert len(prefix_files(tmp_path)) == 1, ( + "Opening an artifact must not synthesize more" + ) + + +async def test_unbalanced_brace_degrades_gracefully(client, tmp_path): + """A user-typed unbalanced brace in an embedded fragment steals the + suffix's closer: diagnostics must appear, and the server must keep + serving requests afterwards.""" + (tmp_path / "list.def").write_text("X(alpha)\nvoid oops() {\n") # unbalanced { + (tmp_path / "main.cpp").write_text( + "#define X(name) int name;\n" + "enum Ids {\n" + '#include "list.def"\n' + "};\n" + "#undef X\n" + "int main() { return 0; }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + def_uri, _ = await client.open_and_wait(tmp_path / "list.def") + diags = client.diagnostics.get(def_uri, []) + assert diags, "The imbalance must surface as diagnostics" + + # The server stays healthy: a follow-up request still answers. + q = await client.query_context(def_uri) + assert get_field(q, "total") >= 1 diff --git a/tests/integration/compilation/test_staleness.py b/tests/integration/compilation/test_staleness.py index 0576868458..8a0cd0d8ef 100644 --- a/tests/integration/compilation/test_staleness.py +++ b/tests/integration/compilation/test_staleness.py @@ -6,6 +6,7 @@ """ import asyncio +import os import shutil import pytest @@ -448,3 +449,110 @@ async def test_flag_change_invalidates_pch(executable, tmp_path): ) assert_no_anomaly(c2, tmp_path) await shutdown_client(c2) + + +async def test_host_change_resynthesizes_preamble(client, tmp_path): + """When the host source stops providing a dependency, the header's + synthesized preamble must be rebuilt from the new disk state.""" + (tmp_path / "types.h").write_text("#pragma once\nstruct Point { int x; int y; };\n") + (tmp_path / "utils.h").write_text("inline int get_x(Point p) { return p.x; }\n") + (tmp_path / "main.cpp").write_text( + '#include "types.h"\n#include "utils.h"\n' + "int main() { Point p{1, 2}; return get_x(p); }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + + # utils.h has no CDB entry: compiled via automatic header context, + # with types.h provided by the synthesized preamble from main.cpp. + utils_uri, _ = await client.open_and_wait(tmp_path / "utils.h") + assert_clean_compile(client, utils_uri) + + # Ensure mtime advances past filesystem granularity (1s on some FSes). + await asyncio.sleep(1.1) + (tmp_path / "main.cpp").write_text('#include "utils.h"\nint main() { return 0; }\n') + + # No didSave: mtime-based chain snapshot must detect the change. + await wait_for_recompile(client, utils_uri) + assert_has_errors( + client, utils_uri, "Expected errors after host stopped providing types.h" + ) + + +async def test_intermediate_change_resynthesizes_preamble(client, tmp_path): + """Changing an intermediate file of the include chain (not the host) + must also invalidate the synthesized preamble.""" + (tmp_path / "wrapper.h").write_text( + '#pragma once\n#define VALUE 42\n#include "target.h"\n' + ) + (tmp_path / "target.h").write_text("inline int get() { return VALUE; }\n") + (tmp_path / "main.cpp").write_text( + '#include "wrapper.h"\nint main() { return get(); }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + + # target.h compiles via chain main.cpp -> wrapper.h, which provides VALUE. + target_uri, _ = await client.open_and_wait(tmp_path / "target.h") + assert_clean_compile(client, target_uri) + + # Rename the macro in the intermediate wrapper.h. + await asyncio.sleep(1.1) + (tmp_path / "wrapper.h").write_text( + '#pragma once\n#define OTHER 42\n#include "target.h"\n' + ) + + await wait_for_recompile(client, target_uri) + assert_has_errors( + client, target_uri, "Expected errors after intermediate header changed" + ) + + +async def test_saved_host_reinvalidates_header(client, tmp_path): + """didSave on a chain file must force preamble re-validation by content + even when the file's mtime is unchanged (the pull path is blind then).""" + (tmp_path / "types.h").write_text("#pragma once\nstruct Point { int x; int y; };\n") + (tmp_path / "utils.h").write_text("inline int get_x(Point p) { return p.x; }\n") + main_cpp = tmp_path / "main.cpp" + main_cpp.write_text( + '#include "types.h"\n#include "utils.h"\n' + "int main() { Point p{1, 2}; return get_x(p); }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + main_uri, _ = await client.open_and_wait(main_cpp) + utils_uri, _ = await client.open_and_wait(tmp_path / "utils.h") + assert_clean_compile(client, utils_uri) + + # Rewrite main.cpp but restore its original mtime: the mtime-based + # Layer 1 check now cannot see the change, only the didSave push path + # (which zeroes build_at, forcing a content re-hash) can catch it. + stat = main_cpp.stat() + main_cpp.write_text('#include "utils.h"\nint main() { return 0; }\n') + os.utime(main_cpp, (stat.st_atime, stat.st_mtime)) + + client.text_document_did_save( + DidSaveTextDocumentParams(text_document=doc(main_uri)) + ) + + await wait_for_recompile(client, utils_uri) + assert_has_errors( + client, utils_uri, "Expected errors after didSave with restored mtime" + ) + + +async def test_orphan_header_default_command(client, tmp_path): + """A header with no CDB entry and no including source falls back to the + synthesized default command and still compiles.""" + (tmp_path / "main.cpp").write_text("int main() { return 0; }\n") + (tmp_path / "orphan.h").write_text("inline int orphan_value() { return 7; }\n") + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + orphan_uri, _ = await client.open_and_wait(tmp_path / "orphan.h") + assert_clean_compile(client, orphan_uri) diff --git a/tests/integration/extensions/test_context_switching.py b/tests/integration/extensions/test_context_switching.py new file mode 100644 index 0000000000..48127fd217 --- /dev/null +++ b/tests/integration/extensions/test_context_switching.py @@ -0,0 +1,312 @@ +"""Integration tests for compilation context switching. + +Covers source files with multiple CDB entries (selected by command hash), +include-occurrence contexts for guard-less headers, context deduplication +by canonical flags, host ranking, and switch validation. +""" + +from tests.integration.utils import get_field, write_cdb, write_entries +from tests.integration.utils.assertions import assert_clean_compile, assert_has_errors +from tests.integration.utils.wait import wait_for_recompile + + +async def test_source_command_switch(client, tmp_path): + """Switching between two CDB entries of one source must recompile it + under the selected flags.""" + (tmp_path / "main.cpp").write_text( + "#ifndef EXPECTED\n#error missing EXPECTED\n#endif\nint main() { return 0; }\n" + ) + write_entries(tmp_path, [("main.cpp", ["-DEXPECTED"]), ("main.cpp", [])]) + await client.initialize(tmp_path) + + # Default entry is the first one: EXPECTED defined, clean. + main_uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + assert_clean_compile(client, main_uri) + + query = await client.query_context(main_uri) + assert get_field(query, "total") == 2 + contexts = get_field(query, "contexts", []) + hashes = {get_field(c, "label"): get_field(c, "commandHash") for c in contexts} + assert all(hashes.values()), ( + f"Source contexts must carry commandHash, got: {contexts}" + ) + plain_hash = next(h for label, h in hashes.items() if "-DEXPECTED" not in label) + defined_hash = next(h for label, h in hashes.items() if "-DEXPECTED" in label) + + # Switch to the entry without the define: the #error must fire. + switch = await client.switch_context(main_uri, main_uri, command_hash=plain_hash) + assert get_field(switch, "success") is True + await wait_for_recompile(client, main_uri) + assert_has_errors(client, main_uri, "Expected #error without -DEXPECTED") + + current = await client.current_context(main_uri) + assert get_field(get_field(current, "context"), "commandHash") == plain_hash + + # And back. + switch = await client.switch_context(main_uri, main_uri, command_hash=defined_hash) + assert get_field(switch, "success") is True + await wait_for_recompile(client, main_uri) + assert_clean_compile(client, main_uri) + + +async def test_occurrence_switch(client, tmp_path): + """A guard-less header included twice by one host provides one context + per include occurrence.""" + (tmp_path / "list.def").write_text("X(alpha)\n") + (tmp_path / "main.cpp").write_text( + "#define X(name) int name;\n" + '#include "list.def"\n' + "#undef X\n" + "#define X(name) void get_##name();\n" + '#include "list.def"\n' + "#undef X\n" + "int main() { return alpha; }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + main_uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + def_uri, _ = await client.open_and_wait(tmp_path / "list.def") + + query = await client.query_context(def_uri) + assert get_field(query, "total") == 2, ( + f"Expected 2 occurrence contexts, got {query}" + ) + contexts = get_field(query, "contexts", []) + occurrences = sorted(get_field(c, "occurrence") for c in contexts) + assert occurrences == [0, 1], f"Expected occurrences 0 and 1, got: {contexts}" + + # Pin each occurrence; both must compile cleanly and be reported back. + for occ in (0, 1): + switch = await client.switch_context(def_uri, main_uri, occurrence=occ) + assert get_field(switch, "success") is True, f"switch to occurrence {occ}" + await wait_for_recompile(client, def_uri) + assert_clean_compile(client, def_uri) + + current = await client.current_context(def_uri) + assert get_field(get_field(current, "context"), "occurrence") == occ + + +async def test_context_dedup_and_ranking(client, tmp_path): + """Hosts with identical canonical flags collapse into one context, and + the representative is the best-ranked host (matching stem wins).""" + (tmp_path / "widget.h").write_text("inline int widget_size() { return 4; }\n") + for name in ("zzz.cpp", "widget.cpp", "aaa.cpp"): + (tmp_path / name).write_text( + f'#include "widget.h"\nint {name[0]}() {{ return widget_size(); }}\n' + ) + write_cdb(tmp_path, ["zzz.cpp", "widget.cpp", "aaa.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "widget.cpp") + # Dedup requires a confirmed self-contained verdict, earned by the + # header's own trial compile — wait for it. + widget_uri, _ = await client.open_and_wait(tmp_path / "widget.h") + + query = await client.query_context(widget_uri) + assert get_field(query, "total") == 1, ( + f"Identical flags must dedupe to one context, got: {query}" + ) + contexts = get_field(query, "contexts", []) + assert "widget.cpp" in get_field(contexts[0], "uri"), ( + f"Representative should be the stem-matching host, got: {contexts}" + ) + + +async def test_switch_rejects_non_includer(client, tmp_path): + """Switching a header to a source that does not include it must fail.""" + (tmp_path / "utils.h").write_text("inline int util() { return 1; }\n") + (tmp_path / "main.cpp").write_text( + '#include "utils.h"\nint main() { return util(); }\n' + ) + (tmp_path / "other.cpp").write_text("int other() { return 2; }\n") + write_cdb(tmp_path, ["main.cpp", "other.cpp"]) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "main.cpp") + utils_uri, _ = client.open(tmp_path / "utils.h") + + other_uri = client.path_to_uri(tmp_path / "other.cpp") + switch = await client.switch_context(utils_uri, other_uri) + assert get_field(switch, "success") is False, ( + "Switching to a non-including host must be rejected" + ) + + +async def test_occurrence_out_of_range(client, tmp_path): + """Pinning an occurrence beyond the include count must be rejected.""" + (tmp_path / "list.def").write_text("X(alpha)\n") + (tmp_path / "main.cpp").write_text( + "#define X(name) int name;\n" + '#include "list.def"\n' + "#undef X\n" + "int main() { return alpha; }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + main_uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + def_uri, _ = client.open(tmp_path / "list.def") + + switch = await client.switch_context(def_uri, main_uri, occurrence=5) + assert get_field(switch, "success") is False, ( + "Out-of-range occurrence must be rejected" + ) + + +async def test_query_context_pagination(client, tmp_path): + """queryContext pages results: 12 distinct configs yield 10 + 2.""" + (tmp_path / "common.h").write_text("inline int common() { return 1; }\n") + entries = [] + for n in range(12): + name = f"s{n:02}.cpp" + (tmp_path / name).write_text( + f'#include "common.h"\nint f{n}() {{ return common() + FLAVOR; }}\n' + ) + entries.append((name, [f"-DFLAVOR={n}"])) + write_entries(tmp_path, entries) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "s00.cpp") + common_uri, _ = client.open(tmp_path / "common.h") + + first = await client.query_context(common_uri) + assert get_field(first, "total") == 12 + assert len(get_field(first, "contexts", [])) == 10 + + second = await client.query_context(common_uri, offset=10) + assert get_field(second, "total") == 12 + assert len(get_field(second, "contexts", [])) == 2 + + # The two pages must not overlap. + first_uris = {get_field(c, "uri") for c in get_field(first, "contexts", [])} + second_uris = {get_field(c, "uri") for c in get_field(second, "contexts", [])} + assert not (first_uris & second_uris) + + +async def test_stale_epoch_rejected(client, tmp_path): + """A switch made against an outdated queryContext listing is rejected + with stale=true; re-querying yields a fresh epoch that works.""" + import asyncio + + from lsprotocol.types import DidSaveTextDocumentParams + + from tests.integration.utils import doc + + (tmp_path / "shared.h").write_text("VALUE_TYPE get_value();\n") + (tmp_path / "main.cpp").write_text( + '#define VALUE_TYPE int\n#include "shared.h"\nint main() { return 0; }\n' + ) + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + main_uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + shared_uri, _ = client.open(tmp_path / "shared.h") + + query = await client.query_context(shared_uri) + old_epoch = get_field(query, "epoch") + assert old_epoch, f"queryContext must stamp an epoch, got: {query}" + + # Any save bumps the workspace epoch. + client.text_document_did_save( + DidSaveTextDocumentParams(text_document=doc(main_uri)) + ) + await asyncio.sleep(0.5) + + switch = await client.switch_context(shared_uri, main_uri, epoch=old_epoch) + assert get_field(switch, "success") is False + assert get_field(switch, "stale") is True, ( + f"Expected stale rejection, got: {switch}" + ) + + fresh = await client.query_context(shared_uri) + switch = await client.switch_context( + shared_uri, main_uri, epoch=get_field(fresh, "epoch") + ) + assert get_field(switch, "success") is True, f"Fresh epoch must work, got: {switch}" + + +async def test_multi_config_host(client, tmp_path): + """A host built under several configurations provides one context per + CDB entry, switchable by command hash.""" + (tmp_path / "render.h").write_text( + "#pragma once\n" + "#if defined(USE_VULKAN)\n" + 'inline const char* backend() { return "vk"; }\n' + "#elif defined(USE_METAL)\n" + 'inline const char* backend() { return "mt"; }\n' + "#endif\n" + ) + (tmp_path / "host.cpp").write_text( + '#include "render.h"\nint main() { return backend()[0]; }\n' + ) + write_entries( + tmp_path, [("host.cpp", ["-DUSE_VULKAN"]), ("host.cpp", ["-DUSE_METAL"])] + ) + await client.initialize(tmp_path) + + await client.open_and_wait(tmp_path / "host.cpp") + render_uri, _ = await client.open_and_wait(tmp_path / "render.h") + + query = await client.query_context(render_uri) + assert get_field(query, "total") == 2, query + contexts = get_field(query, "contexts", []) + hashes = [get_field(c, "commandHash") for c in contexts] + assert all(hashes) and len(set(hashes)) == 2, contexts + + metal_hash = next( + get_field(c, "commandHash") + for c in contexts + if "USE_METAL" in get_field(c, "label") + ) + host_uri = client.path_to_uri(tmp_path / "host.cpp") + switch = await client.switch_context(render_uri, host_uri, command_hash=metal_hash) + assert get_field(switch, "success") is True, switch + + await wait_for_recompile(client, render_uri) + assert_clean_compile(client, render_uri) + current = await client.current_context(render_uri) + ctx = get_field(current, "context") + assert get_field(ctx, "commandHash") == metal_hash, current + + +async def test_saved_include_updates_hosts(client, tmp_path): + """Adding an #include and saving must immediately expose the new host + in queryContext: the include graph is rescanned on didSave.""" + from lsprotocol.types import ( + DidChangeTextDocumentParams, + DidSaveTextDocumentParams, + TextDocumentContentChangeWholeDocument, + VersionedTextDocumentIdentifier, + ) + + from tests.integration.utils import doc + + (tmp_path / "lonely.h").write_text("inline int lonely() { return 1; }\n") + main_cpp = tmp_path / "main.cpp" + main_cpp.write_text("int main() { return 0; }\n") + write_cdb(tmp_path, ["main.cpp"]) + await client.initialize(tmp_path) + + main_uri, _ = await client.open_and_wait(main_cpp) + lonely_uri, _ = client.open(tmp_path / "lonely.h") + + query = await client.query_context(lonely_uri) + assert get_field(query, "total") == 0, "No includers yet" + + # Include the header and save. + new_text = '#include "lonely.h"\nint main() { return lonely(); }\n' + main_cpp.write_text(new_text) + client.text_document_did_change( + DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier(uri=main_uri, version=2), + content_changes=[TextDocumentContentChangeWholeDocument(text=new_text)], + ) + ) + client.text_document_did_save( + DidSaveTextDocumentParams(text_document=doc(main_uri)) + ) + + query = await client.query_context(lonely_uri) + assert get_field(query, "total") == 1, f"New host must appear after save: {query}" + assert "main.cpp" in get_field(get_field(query, "contexts")[0], "uri") diff --git a/tests/integration/extensions/test_header_context.py b/tests/integration/extensions/test_header_context.py index 4b7ff7d855..0ccc9bbbe2 100644 --- a/tests/integration/extensions/test_header_context.py +++ b/tests/integration/extensions/test_header_context.py @@ -17,13 +17,7 @@ TextDocumentIdentifier, ) -from tests.integration.utils import doc - - -def get_field(obj, key, default=None): - if isinstance(obj, dict): - return obj.get(key, default) - return getattr(obj, key, default) +from tests.integration.utils import doc, get_field, write_entries @pytest.mark.workspace("header_context") @@ -213,3 +207,37 @@ async def test_query_context_multiple_cdb_entries(client, workspace): # Each entry should have distinguishing flags in the label. assert any("CONFIG_A" in l for l in labels), f"Should find CONFIG_A, got: {labels}" assert any("CONFIG_B" in l for l in labels), f"Should find CONFIG_B, got: {labels}" + + +async def test_switch_between_two_hosts(client, tmp_path): + """Switching a header between two hosts with different macro setups must + recompile it under the new host's preamble.""" + (tmp_path / "shared.h").write_text("VALUE_TYPE get_value();\n") + (tmp_path / "a.cpp").write_text( + '#define VALUE_TYPE int\n#include "shared.h"\nint main() { return 0; }\n' + ) + (tmp_path / "b.cpp").write_text( + '#define VALUE_TYPE float\n#include "shared.h"\nfloat f() { return 0; }\n' + ) + write_entries(tmp_path, [("a.cpp", []), ("b.cpp", [])]) + await client.initialize(tmp_path) + + a_uri, _ = await client.open_and_wait(tmp_path / "a.cpp") + b_uri, _ = await client.open_and_wait(tmp_path / "b.cpp") + shared_uri, _ = client.open(tmp_path / "shared.h") + + async def hover_get_value_contains(text): + hover = await client.hover_at(shared_uri, 0, 12) # 'get_value' + assert hover is not None, "Hover on get_value should work" + return text in str(hover.contents) + + # Host a.cpp: VALUE_TYPE is int. + switch = await client.switch_context(shared_uri, a_uri) + assert get_field(switch, "success") is True + assert await hover_get_value_contains("int"), "Expected int under host a.cpp" + + # Host b.cpp: VALUE_TYPE is float. This exercises the cached-context + # invalidation branch (active context differs from cached host). + switch = await client.switch_context(shared_uri, b_uri) + assert get_field(switch, "success") is True + assert await hover_get_value_contains("float"), "Expected float under host b.cpp" diff --git a/tests/integration/features/test_inactive_regions.py b/tests/integration/features/test_inactive_regions.py new file mode 100644 index 0000000000..50a771d4db --- /dev/null +++ b/tests/integration/features/test_inactive_regions.py @@ -0,0 +1,89 @@ +"""Integration tests for the clice/inactiveRegions notification. + +Pushed after every compile with the untaken #if branch bodies of the +current compilation context. The preamble's share comes from the PCH +build (conditions inside the bound never replay in the AST compile); +a #if cut by the bound resumes via the open-conditional stack. +""" + +import asyncio + +from tests.integration.utils import write_cdb, write_entries + + +async def wait_regions(captured, timeout=15.0): + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + if captured and captured[-1].regions: + return captured[-1].regions + await asyncio.sleep(0.05) + return captured[-1].regions if captured else [] + + +def capture(client): + captured = [] + + @client.feature("clice/inactiveRegions") + def on_inactive(params): + captured.append(params) + + return captured + + +async def test_inactive_after_bound(client, tmp_path): + """Conditions entirely past the preamble bound (no PCH involvement).""" + (tmp_path / "main.cpp").write_text( + "int a();\n#if 0\nint dead();\n#endif\nint main() { return 0; }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + captured = capture(client) + + await client.initialize(tmp_path) + await client.open_and_wait(tmp_path / "main.cpp") + regions = await wait_regions(captured) + assert [(r.start.line, r.end.line) for r in regions] == [(2, 3)], regions + + +async def test_inactive_across_bound(client, tmp_path): + """A #if inside the preamble bound lives in the PCH; its #elif/#endif + replay in the AST compile and resume from the PCH's open stack.""" + (tmp_path / "render.h").write_text( + "#pragma once\n" + "#if defined(USE_VULKAN)\n" + 'inline const char* backend() { return "vk"; }\n' + "#elif defined(USE_METAL)\n" + 'inline const char* backend() { return "mt"; }\n' + "#endif\n" + ) + (tmp_path / "render_vk.cpp").write_text( + '#include "render.h"\nint main() { return backend()[0]; }\n' + ) + write_entries(tmp_path, [("render_vk.cpp", ["-DUSE_VULKAN"])]) + captured = capture(client) + + await client.initialize(tmp_path) + await client.open_and_wait(tmp_path / "render.h") + regions = await wait_regions(captured) + assert [(r.start.line, r.end.line) for r in regions] == [(4, 5)], regions + + +async def test_inactive_else_branch(client, tmp_path): + """#else carries no condition value; inactivity is derived from + whether an earlier branch was taken.""" + (tmp_path / "main.cpp").write_text( + "#define USE_A 1\n" + "int x();\n" + "#if USE_A\n" + "int active();\n" + "#else\n" + "int dead();\n" + "#endif\n" + "int main() { return 0; }\n" + ) + write_cdb(tmp_path, ["main.cpp"]) + captured = capture(client) + + await client.initialize(tmp_path) + await client.open_and_wait(tmp_path / "main.cpp") + regions = await wait_regions(captured) + assert [(r.start.line, r.end.line) for r in regions] == [(5, 6)], regions diff --git a/tests/integration/utils/__init__.py b/tests/integration/utils/__init__.py index 541c90fecd..e511352316 100644 --- a/tests/integration/utils/__init__.py +++ b/tests/integration/utils/__init__.py @@ -1,7 +1,13 @@ """Shared utilities for clice integration tests.""" from tests.integration.utils.client import CliceClient -from tests.integration.utils.workspace import doc, write_cdb, write_source +from tests.integration.utils.workspace import ( + doc, + get_field, + write_cdb, + write_entries, + write_source, +) from tests.integration.utils.assertions import ( assert_no_errors, assert_has_errors, @@ -16,6 +22,8 @@ ) __all__ = [ + "get_field", + "write_entries", "CliceClient", "doc", "write_cdb", diff --git a/tests/integration/utils/cache.py b/tests/integration/utils/cache.py index bf9137ce0f..cd6cc44e1e 100644 --- a/tests/integration/utils/cache.py +++ b/tests/integration/utils/cache.py @@ -5,7 +5,7 @@ # Versioned root of the unified cache store; bump together with # cache_format_version in src/server/workspace/workspace.h. -CACHE_ROOT = Path(".clice") / "cache" / "v1" +CACHE_ROOT = Path(".clice") / "cache" / "v2" def cache_root(workspace: Path) -> Path: diff --git a/tests/integration/utils/client.py b/tests/integration/utils/client.py index 5400636e39..86ed038998 100644 --- a/tests/integration/utils/client.py +++ b/tests/integration/utils/client.py @@ -371,10 +371,15 @@ async def format_range(self, uri: str, range_: Range, *, timeout: float = 30.0): # ── Extension protocol ─────────────────────────────────────────── - async def query_context(self, uri: str, *, timeout: float = 30.0): + async def query_context( + self, uri: str, *, offset: int | None = None, timeout: float = 30.0 + ): """Send clice/queryContext extension request.""" + params = {"uri": uri} + if offset is not None: + params["offset"] = offset return await asyncio.wait_for( - self.protocol.send_request_async("clice/queryContext", {"uri": uri}), + self.protocol.send_request_async("clice/queryContext", params), timeout=timeout, ) @@ -386,12 +391,24 @@ async def current_context(self, uri: str, *, timeout: float = 30.0): ) async def switch_context( - self, uri: str, context_uri: str, *, timeout: float = 30.0 + self, + uri: str, + context_uri: str, + *, + occurrence: int | None = None, + command_hash: str | None = None, + epoch: int | None = None, + timeout: float = 30.0, ): """Send clice/switchContext extension request.""" + params = {"uri": uri, "contextUri": context_uri} + if occurrence is not None: + params["occurrence"] = occurrence + if command_hash is not None: + params["commandHash"] = command_hash + if epoch is not None: + params["epoch"] = epoch return await asyncio.wait_for( - self.protocol.send_request_async( - "clice/switchContext", {"uri": uri, "contextUri": context_uri} - ), + self.protocol.send_request_async("clice/switchContext", params), timeout=timeout, ) diff --git a/tests/integration/utils/workspace.py b/tests/integration/utils/workspace.py index ffd0d51b1e..4333a80327 100644 --- a/tests/integration/utils/workspace.py +++ b/tests/integration/utils/workspace.py @@ -63,3 +63,35 @@ def did_change(client, uri: str, version: int, text: str) -> None: content_changes=[TextDocumentContentChangeWholeDocument(text=text)], ) ) + + +def get_field(obj, key, default=None): + """Read a field from a dict or attribute-style LSP response object.""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def write_entries(workspace, entries): + """Write a compile_commands.json with per-file extra arguments. + + Args: + workspace: Root directory of the workspace. + entries: List of (file_name, extra_args) pairs; a file may appear + multiple times to model multi-configuration projects. + """ + data = [ + { + "directory": str(workspace), + "file": str(workspace / f), + "arguments": [ + "clang++", + "-std=c++17", + "-fsyntax-only", + *args, + str(workspace / f), + ], + } + for f, args in entries + ] + (workspace / "compile_commands.json").write_text(json.dumps(data)) diff --git a/tests/unit/feature/inactive_region_tests.cpp b/tests/unit/feature/inactive_region_tests.cpp new file mode 100644 index 0000000000..f4201df396 --- /dev/null +++ b/tests/unit/feature/inactive_region_tests.cpp @@ -0,0 +1,90 @@ +#include "test/test.h" +#include "test/tester.h" +#include "feature/inactive_regions.h" + +namespace clice::testing { + +namespace { + +TEST_SUITE(inactive_regions, Tester) { + +feature::InactiveScan scan; + +void run(llvm::StringRef source) { + add_main("main.cpp", source); + ASSERT_TRUE(compile("-std=c++17")); + scan = feature::inactive_regions(*unit); +} + +TEST_CASE(FalseBranch) { + run(R"cpp( +int a(); +#if 0 +int dead(); +#endif +int b(); +)cpp"); + + ASSERT_EQ(scan.regions.size(), 2u); + auto content = unit->interested_content(); + auto begin = scan.regions[0]; + auto end = scan.regions[1]; + EXPECT_EQ(content.substr(begin, end - begin), "int dead();\n"); +} + +TEST_CASE(ElseBranch) { + run(R"cpp( +#define USE_A 1 +#if USE_A +int active(); +#else +int dead(); +#endif +)cpp"); + + ASSERT_EQ(scan.regions.size(), 2u); + auto content = unit->interested_content(); + EXPECT_EQ(content.substr(scan.regions[0], scan.regions[1] - scan.regions[0]), "int dead();\n"); +} + +TEST_CASE(IncludeGuardStaysActive) { + run(R"cpp( +#ifndef GUARD_H +int active(); +#endif +)cpp"); + + EXPECT_EQ(scan.regions.size(), 0u); +} + +TEST_CASE(IfndefDefinedMacro) { + run(R"cpp( +#define TAKEN 1 +#ifndef TAKEN +int dead(); +#endif +)cpp"); + + ASSERT_EQ(scan.regions.size(), 2u); + auto content = unit->interested_content(); + EXPECT_EQ(content.substr(scan.regions[0], scan.regions[1] - scan.regions[0]), "int dead();\n"); +} + +TEST_CASE(IfdefUndefinedMacro) { + run(R"cpp( +#ifdef MISSING +int dead(); +#endif +int b(); +)cpp"); + + ASSERT_EQ(scan.regions.size(), 2u); + auto content = unit->interested_content(); + EXPECT_EQ(content.substr(scan.regions[0], scan.regions[1] - scan.regions[0]), "int dead();\n"); +} + +}; // TEST_SUITE(inactive_regions) + +} // namespace + +} // namespace clice::testing diff --git a/tests/unit/server/rank_hosts_tests.cpp b/tests/unit/server/rank_hosts_tests.cpp new file mode 100644 index 0000000000..76584a8ac7 --- /dev/null +++ b/tests/unit/server/rank_hosts_tests.cpp @@ -0,0 +1,52 @@ +#include "test/test.h" +#include "server/workspace/workspace.h" + +namespace clice::testing { +namespace { + +TEST_SUITE(RankHosts) { + +TEST_CASE(StemMatchWins) { + Workspace ws; + auto header = ws.path_pool.intern("/proj/src/utils.h"); + auto stem = ws.path_pool.intern("/other/utils.cpp"); + auto same_dir = ws.path_pool.intern("/proj/src/main.cpp"); + + auto ranked = ws.rank_hosts(header, {same_dir, stem}); + ASSERT_EQ(ranked.size(), 2u); + EXPECT_EQ(ranked[0], stem); + EXPECT_EQ(ranked[1], same_dir); +} + +TEST_CASE(SameDirBeatsProximity) { + Workspace ws; + auto header = ws.path_pool.intern("/proj/src/utils.h"); + auto same_dir = ws.path_pool.intern("/proj/src/zzz.cpp"); + auto sibling_dir = ws.path_pool.intern("/proj/lib/aaa.cpp"); + + auto ranked = ws.rank_hosts(header, {sibling_dir, same_dir}); + ASSERT_EQ(ranked.size(), 2u); + EXPECT_EQ(ranked[0], same_dir); +} + +TEST_CASE(ProximityThenLexicographic) { + Workspace ws; + auto header = ws.path_pool.intern("/proj/a/b/utils.h"); + auto near = ws.path_pool.intern("/proj/a/main.cpp"); + auto far = ws.path_pool.intern("/proj/x/main.cpp"); + + auto ranked = ws.rank_hosts(header, {far, near}); + ASSERT_EQ(ranked.size(), 2u); + EXPECT_EQ(ranked[0], near); + + auto first = ws.path_pool.intern("/proj/x/aaa.cpp"); + auto second = ws.path_pool.intern("/proj/x/bbb.cpp"); + auto tie = ws.rank_hosts(header, {second, first}); + ASSERT_EQ(tie.size(), 2u); + EXPECT_EQ(tie[0], first); +} + +}; // TEST_SUITE(RankHosts) + +} // namespace +} // namespace clice::testing diff --git a/tests/unit/syntax/preamble_synthesis_tests.cpp b/tests/unit/syntax/preamble_synthesis_tests.cpp new file mode 100644 index 0000000000..e7417d1b57 --- /dev/null +++ b/tests/unit/syntax/preamble_synthesis_tests.cpp @@ -0,0 +1,491 @@ +#include "test/test.h" +#include "syntax/preamble_synthesis.h" + +#include "llvm/ADT/StringMap.h" + +namespace clice::testing { +namespace { + +/// Build a resolver from a fixed raw-name -> absolute-path mapping. +auto map_resolver(const llvm::StringMap& mapping) { + return [&mapping](llvm::StringRef name, + [[maybe_unused]] bool is_angled, + [[maybe_unused]] bool is_include_next, + [[maybe_unused]] llvm::StringRef includer_dir) -> std::optional { + auto it = mapping.find(name); + if(it == mapping.end()) { + return std::nullopt; + } + return it->second; + }; +} + +TEST_SUITE(PreambleSynthesis) { + +TEST_CASE(BasicChain) { + llvm::StringMap mapping = { + {"vector", "/sys/vector" }, + {"utils.h", "/proj/utils.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#include +#define DEBUG 1 +#include "utils.h" +int main() {} +)"}; + + auto result = synthesize_preamble({entry}, "/proj/utils.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#include +#define DEBUG 1 +)"); +} + +TEST_CASE(MultiLevelChain) { + llvm::StringMap mapping = { + {"utils.h", "/proj/utils.h"}, + {"string", "/sys/string" }, + {"math.h", "/proj/math.h" }, + }; + + ChainEntry main_entry{"/proj/main.cpp", R"(#include "utils.h" +int main() {} +)"}; + ChainEntry utils_entry{"/proj/utils.h", R"(#pragma once +#include +#include "math.h" +void util_func(); +)"}; + + auto result = + synthesize_preamble({main_entry, utils_entry}, "/proj/math.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#line 1 "/proj/utils.h" +#pragma once +#include +)"); +} + +TEST_CASE(SameBasenameHeaders) { + // Two headers with the same filename in different directories: the + // resolver must disambiguate, a filename match would pick the wrong one. + llvm::StringMap mapping = { + {"a/config.h", "/proj/a/config.h"}, + {"b/config.h", "/proj/b/config.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#include "a/config.h" +#include "b/config.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/b/config.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#include "/proj/a/config.h" +)"); +} + +TEST_CASE(QuotedIncludeAbsolutized) { + // The preamble file lives in the cache directory, so quoted includes + // must be rewritten to absolute paths; angled includes stay untouched. + llvm::StringMap mapping = { + {"vector", "/sys/vector" }, + {"types.h", "/proj/types.h"}, + {"utils.h", "/proj/utils.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#include +#include "types.h" +#include "utils.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/utils.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#include +#include "/proj/types.h" +)"); +} + +TEST_CASE(FilenameFallback) { + // Resolution fails entirely — an unambiguous filename match still works. + llvm::StringMap empty; + + ChainEntry entry{"/proj/main.cpp", R"(#include +#include "utils.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/utils.h", map_resolver(empty)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#include +)"); +} + +TEST_CASE(AmbiguousFallbackFails) { + // Resolution fails and two candidates share the target's filename: + // refuse to guess. + llvm::StringMap empty; + + ChainEntry entry{"/proj/main.cpp", R"(#include "a/config.h" +#include "b/config.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/b/config.h", map_resolver(empty)); + EXPECT_FALSE(result.has_value()); +} + +TEST_CASE(NoMatchFails) { + llvm::StringMap mapping = { + {"vector", "/sys/vector"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#include +)"}; + + auto result = synthesize_preamble({entry}, "/proj/utils.h", map_resolver(mapping)); + EXPECT_FALSE(result.has_value()); +} + +TEST_CASE(CommentedIncludeIgnored) { + llvm::StringMap mapping = { + {"target.h", "/proj/target.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(/* +#include "target.h" +*/ +#include "target.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/target.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +/* +#include "target.h" +*/ +)"); +} + +TEST_CASE(LineMarkerEscaping) { + llvm::StringMap mapping = { + {"utils.h", R"(C:\proj\utils.h)"}, + }; + + ChainEntry entry{R"(C:\proj\main.cpp)", R"(#include "utils.h" +)"}; + + auto result = synthesize_preamble({entry}, R"(C:\proj\utils.h)", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "C:\\proj\\main.cpp" +)"); +} + +TEST_CASE(ConditionalShadowSkipped) { + // An include of the target inside an #if block must not shadow the + // real, unconditional one — the cut lands at the latter. + llvm::StringMap mapping = { + {"target.h", "/proj/target.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#if 0 +#include "target.h" +#endif +#define X 1 +#include "target.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/target.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + // The shadowed occurrence of the target itself is blanked (line kept): + // at compile time the target's path is remapped to the open buffer. + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#if 0 + +#endif +#define X 1 +)"); +} + +TEST_CASE(GuardChainBalanced) { + // Cutting inside a classic include guard must close the open #ifndef, + // or clang reports an unterminated conditional in the preamble. + llvm::StringMap mapping = { + {"target.h", "/proj/target.h"}, + }; + + ChainEntry entry{"/proj/a.h", R"(#ifndef A_H +#define A_H +struct A {}; +#include "target.h" +#endif +)"}; + + auto result = synthesize_preamble({entry}, "/proj/target.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/a.h" +#ifndef A_H +#define A_H +struct A {}; +#endif +)"); +} + +TEST_CASE(OnlyConditionalMatch) { + // Platform-conditional include: the only match is conditional, so the + // cut lands inside the block and the #ifdef is balanced. + llvm::StringMap mapping = { + {"impl.h", "/proj/impl.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#ifdef _WIN32 +#include "impl.h" +#endif +)"}; + + auto result = synthesize_preamble({entry}, "/proj/impl.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#ifdef _WIN32 +#endif +)"); +} + +TEST_CASE(DuplicateSpellingFallback) { + // Resolution fails and two directives share the same raw spelling: + // they bring in the same file, so prefer the unconditional one instead + // of treating this as ambiguous. + llvm::StringMap empty; + + ChainEntry entry{"/proj/main.cpp", R"(#ifdef FAST +#include "impl.h" +#endif +#include "impl.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/impl.h", map_resolver(empty)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#ifdef FAST +#include "impl.h" +#endif +)"); +} + +TEST_CASE(DuplicateIncludeFirstCut) { + // Two identical unconditional includes of the target: cut at the first. + llvm::StringMap mapping = { + {"target.h", "/proj/target.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#include "target.h" +#define Y 1 +#include "target.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/target.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +)"); +} + +TEST_CASE(MacroIncludeIgnored) { + // #include MACRO carries no header-name token; scan() skips it, so it + // is kept verbatim and never considered for matching. + llvm::StringMap mapping = { + {"target.h", "/proj/target.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#include CONFIG_H +#include "target.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/target.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#include CONFIG_H +)"); +} + +TEST_CASE(IncludeNextKeptVerbatim) { + // #include_next search-resume semantics cannot survive relocation into + // the cache directory; it is deliberately not rewritten. + llvm::StringMap mapping = { + {"impl.h", "/x/impl.h" }, + {"target.h", "/proj/target.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#include_next "impl.h" +#include "target.h" +)"}; + + auto result = synthesize_preamble({entry}, "/proj/target.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, R"(#line 1 "/proj/main.cpp" +#include_next "impl.h" +)"); +} + +TEST_CASE(OccurrenceSelectsMatch) { + // Explicit occurrence indexes the candidate list of the direct + // includer, overriding the prefer-unconditional default. + llvm::StringMap mapping = { + {"list.def", "/proj/list.def"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#define X(name) int name; +#include "list.def" +#undef X +#define X(name) void get_##name(); +#include "list.def" +)"}; + + auto second = + synthesize_preamble({entry}, "/proj/list.def", map_resolver(mapping), std::uint32_t(1)); + ASSERT_TRUE(second.has_value()); + // The other occurrence of the target is blanked (line kept). + EXPECT_EQ(*second, R"(#line 1 "/proj/main.cpp" +#define X(name) int name; + +#undef X +#define X(name) void get_##name(); +)"); + + auto first = + synthesize_preamble({entry}, "/proj/list.def", map_resolver(mapping), std::uint32_t(0)); + ASSERT_TRUE(first.has_value()); + EXPECT_EQ(*first, R"(#line 1 "/proj/main.cpp" +#define X(name) int name; +)"); +} + +TEST_CASE(OccurrenceOutOfRange) { + llvm::StringMap mapping = { + {"target.h", "/proj/target.h"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(#include "target.h" +)"}; + + auto result = + synthesize_preamble({entry}, "/proj/target.h", map_resolver(mapping), std::uint32_t(1)); + EXPECT_FALSE(result.has_value()); +} + +TEST_CASE(CountOccurrences) { + llvm::StringMap empty; + + auto count = count_include_occurrences(R"(#include "list.def" +#undef X +#include "list.def" +)", + "/proj/main.cpp", + "/proj/list.def", + map_resolver(empty)); + EXPECT_EQ(count, 2u); +} + +TEST_CASE(CrlfLineEndings) { + llvm::StringMap empty; + + // CR cannot appear in a raw literal cleanly; escaped string is clearer. + ChainEntry entry{"/proj/main.cpp", "#include \"a.h\"\r\n#include \"target.h\"\r\n"}; + + auto result = synthesize_preamble({entry}, "/proj/target.h", map_resolver(empty)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, "#line 1 \"/proj/main.cpp\"\n#include \"a.h\"\r\n"); +} + +TEST_CASE(EmptyChain) { + llvm::StringMap empty; + + auto result = + synthesize_preamble(llvm::ArrayRef(), "/proj/x.h", map_resolver(empty)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, ""); +} + +TEST_CASE(SuffixClosesBraces) { + // Function-body X-macro: the suffix restores the closing brace and + // trailing directives after the include position. + llvm::StringMap mapping = { + {"errors.def", "/proj/errors.def"}, + }; + + ChainEntry entry{"/proj/main.cpp", R"(void register_all() { +#define X(name) handle(name); +#include "errors.def" +#undef X +} +)"}; + + auto result = synthesize_context({entry}, "/proj/errors.def", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->prefix, R"(#line 1 "/proj/main.cpp" +void register_all() { +#define X(name) handle(name); +)"); + EXPECT_EQ(result->suffix, R"(#line 4 "/proj/main.cpp" +#undef X +} +)"); +} + +TEST_CASE(SuffixReopensGuard) { + // The prefix closed the include guard early with a balancing #endif; + // the suffix reopens it with `#if 1` so its own #endif stays matched. + llvm::StringMap mapping = { + {"target.h", "/proj/target.h"}, + }; + + ChainEntry entry{"/proj/a.h", R"(#ifndef A_H +#define A_H +#include "target.h" +void tail(); +#endif +)"}; + + auto result = synthesize_context({entry}, "/proj/target.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->prefix, R"(#line 1 "/proj/a.h" +#ifndef A_H +#define A_H +#endif +)"); + EXPECT_EQ(result->suffix, R"(#if 1 +#line 4 "/proj/a.h" +void tail(); +#endif +)"); +} + +TEST_CASE(SuffixMirrorsChain) { + // Multi-level: the suffix is assembled innermost-first, mirroring the + // prefix's host-first order. + llvm::StringMap mapping = { + {"mid.h", "/proj/mid.h" }, + {"target.h", "/proj/target.h"}, + }; + + ChainEntry host{"/proj/main.cpp", R"(#include "mid.h" +int main() {} +)"}; + ChainEntry mid{"/proj/mid.h", R"(#include "target.h" +void mid_tail(); +)"}; + + auto result = synthesize_context({host, mid}, "/proj/target.h", map_resolver(mapping)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->suffix, R"(#line 2 "/proj/mid.h" +void mid_tail(); +#line 2 "/proj/main.cpp" +int main() {} +)"); +} + +}; // TEST_SUITE(PreambleSynthesis) + +} // namespace +} // namespace clice::testing diff --git a/tests/unit/syntax/scan_tests.cpp b/tests/unit/syntax/scan_tests.cpp index cca19291f4..556b6b86ef 100644 --- a/tests/unit/syntax/scan_tests.cpp +++ b/tests/unit/syntax/scan_tests.cpp @@ -25,6 +25,28 @@ int x = 1; EXPECT_TRUE(result.module_name.empty()); } +TEST_CASE(IncludeOffsets) { + llvm::StringRef content = R"(int x; +#include +#include "b.h" +)"; + auto result = scan(content); + + ASSERT_EQ(result.includes.size(), 2u); + EXPECT_EQ(result.includes[0].offset, static_cast(content.find("#include (content.find(R"(#include "b)"))); +} + +TEST_CASE(IndentedIncludeOffset) { + llvm::StringRef content = " # include \n"; + auto result = scan(content); + + ASSERT_EQ(result.includes.size(), 1u); + // The offset points at the `#`, not the line start. + EXPECT_EQ(result.includes[0].offset, 2u); +} + TEST_CASE(ConditionalIncludes) { auto result = scan(R"( #include