Compare commits

..
Author SHA1 Message Date
l0ng-ai ac7139c94d feat(plugins): open two texts in the diff viewer (API 1.7)
Plugins could show their own content in an editor tab (open-content-tab) but
could open the diff viewer only as the reply to one of that tab's actions. A
review plugin (a merge request's files, say) had no way to show a file's
before and after.

views.open-diff opens two texts in the invoking window's diff viewer, with
optional labels and title and a file name that picks the syntax highlighting;
web views send { op: "open-diff", ... }. The SDK wraps it as host::open_diff
with a mock. The plugin API moves to 1.7.0 (1.6.0 is released) and the checked-in
components are rebuilt.
2026-11-13 11:47:06 +08:00
812 changed files with 3803 additions and 33656 deletions

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=271.2085.0
KOTLIN_LSP=263.7084.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
+1 -1
View File
@@ -119,7 +119,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
+2 -11
View File
@@ -78,7 +78,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}", cache-on-failure: true }
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
@@ -151,13 +151,6 @@ jobs:
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
pnpm exec tauri signer sign --app-version $version $asset
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
gh release upload $env:TAG "$asset.sig" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
@@ -181,7 +174,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}", cache-on-failure: true }
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
@@ -240,8 +233,6 @@ jobs:
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
];
const platforms = {};
for (const sig of sigs) {
Generated
+44 -45
View File
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"futures",
"ide-core",
@@ -4288,12 +4288,11 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
name = "ide-db"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4329,7 +4328,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"serde",
@@ -4342,7 +4341,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4363,7 +4362,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4375,7 +4374,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4388,7 +4387,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"criterion",
"ide-core",
@@ -4406,7 +4405,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4477,7 +4476,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4496,7 +4495,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"criterion",
@@ -4518,7 +4517,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4530,7 +4529,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"encoding_rs",
"futures",
@@ -4547,7 +4546,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"libc",
"tempfile",
@@ -4556,7 +4555,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"futures",
"serde",
@@ -4567,7 +4566,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"criterion",
@@ -4624,7 +4623,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4652,7 +4651,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"bytes",
"dunce",
@@ -4671,7 +4670,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"futures",
"ide-core",
@@ -4694,7 +4693,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -4717,7 +4716,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4731,7 +4730,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"futures",
@@ -4751,7 +4750,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4775,7 +4774,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-tools",
@@ -4791,7 +4790,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dunce",
"ide-core",
@@ -4812,7 +4811,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"criterion",
"encoding_rs",
@@ -4833,7 +4832,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"parking_lot",
@@ -4846,7 +4845,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4871,7 +4870,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-language",
@@ -4886,7 +4885,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"ide-core",
"libc",
@@ -4899,7 +4898,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"criterion",
"ide-core",
@@ -4912,7 +4911,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"flate2",
"hex",
@@ -4933,7 +4932,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -4954,7 +4953,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"chardetng",
"dunce",
@@ -4978,7 +4977,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4994,7 +4993,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -6595,7 +6594,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.44"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.44"
version = "0.1.43"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+13 -4
View File
@@ -9,10 +9,6 @@
The IDE you know, rebuilt in Rust.
</p>
<p align="center">
English · <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
&nbsp;·&nbsp;
@@ -23,6 +19,8 @@
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
&nbsp;·&nbsp;
<a href="#sponsor">Sponsor</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -72,6 +70,17 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
## Sponsor
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
</tr>
</table>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
-88
View File
@@ -1,88 +0,0 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="56">
</picture>
</p>
<p align="center">
你熟悉的 IDE,用 Rust 重新打造。
</p>
<p align="center">
<a href="README.md">English</a> · 简体中文
</p>
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>下载</b></a>
&nbsp;·&nbsp;
<a href="https://openidea.sh/zh">文档</a>
&nbsp;·&nbsp;
<a href="#性能对比">性能对比</a>
&nbsp;·&nbsp;
<a href="#插件">插件</a>
&nbsp;·&nbsp;
<a href="#社区">社区</a>
&nbsp;·&nbsp;
<a href="#赞助">赞助</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
OpenIDEA 把 IntelliJ IDEA 的使用方式(快捷键、工具窗口、随处搜索的习惯)带到一个以 Rust
为核心的原生桌面应用里。
编辑器、代码洞察、调试器、Git、终端、数据库客户端和 HTTP 客户端,支持 60 多种语言。
提供 macOS、Windows 和 Linux 版本。
## 性能对比
在缓存已预热的情况下打开 [freqtrade](https://github.com/freqtrade/freqtrade)(Python,约 770 个文件),
取 5 次运行的中位数:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| --------------------- | -------------- | ---------------------- |
| 显示项目窗口 | **0.9 s** | 3.1 s |
| 就绪(CPU 平稳) | **1.1 s** | 13.2 s |
| 内存(USS,所有进程) | **169 MB** | 2011 MB |
| 就绪前消耗的 CPU 时间 | **2.4 s** | 47.9 s |
| 空闲 CPU | 单核的 **4 %** | 单核的 7 % |
| 退出 | **0.3 s** | 5.9 s |
测试使用 `main` 分支的 OpenIDEA release 构建,以及默认设置和插件的 IDEA,环境为 Windows 11、
32 核 AMD、62 GB 内存。IDEA 对 Python 的分析更深入,所以两边的“就绪”并不代表完成了同样多的工作。
两者都以相同方式从外部测量:可以用 [`perf/compare`](perf/compare) 在自己的机器和项目上复现。
## 插件
插件可以添加操作、补全、快速修复、工具窗口、自定义编辑器、主题、文件图标和语言。插件以沙箱化的
WebAssembly 组件运行;主题、图标主题和语言无需编写代码。
在“设置 › 插件”中安装,或用 `openidea-plugin new` 创建自己的插件:参见
[编写 OpenIDEA 插件](https://openidea.sh/zh/plugins)和[官方插件](plugins/official)。插件 API 目前仍是预览版。
## 社区
- [Discord](https://discord.gg/2Zy4yn7gP):提问、反馈和版本发布消息。
- 微信群,扫码加入:
<img src=".github/assets/wechat-group.png" alt="微信群二维码" width="200">
- 参与贡献:参见 [CONTRIBUTING.md](CONTRIBUTING.md) 和[贡献者文档](https://openidea.sh/zh/contribute/setup)。
## 赞助
如果 OpenIDEA 帮你节省了时间,可以通过支付宝或微信支付支持它的开发。
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="支付宝收款码" width="200"><br>支付宝</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="微信支付收款码" width="200"><br>微信支付</td>
</tr>
</table>
## 许可证
[Apache 2.0](LICENSE)。随附的第三方资源列在 [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) 中。
<sub>与 JetBrains 无关联。IntelliJ IDEA 是 JetBrains s.r.o. 的商标。</sub>
+3 -43
View File
@@ -3,13 +3,11 @@
//!
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line, named in
//! `<home>/.codex/session_index.jsonl`).
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
use super::native::claude::history;
use serde::Serialize;
use specta::Type;
use std::collections::HashMap;
use std::io::BufRead;
use std::path::{Path, PathBuf};
@@ -208,39 +206,11 @@ fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
}
}
/// Thread names Codex keeps in `session_index.jsonl`, by thread id. A rename adds a line,
/// so the last one wins.
fn codex_names(codex_home: &Path) -> HashMap<String, String> {
let Ok(file) = std::fs::File::open(codex_home.join("session_index.jsonl")) else { return HashMap::new() };
let mut names = HashMap::new();
for line in std::io::BufReader::new(file).lines() {
let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
let id = v.get("id").and_then(serde_json::Value::as_str);
let name = v.get("thread_name").and_then(serde_json::Value::as_str).map(str::trim).filter(|n| !n.is_empty());
if let (Some(id), Some(name)) = (id, name) {
names.insert(id.to_owned(), name.to_owned());
}
}
names
}
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`, titled by their
/// thread names, else by their first prompts.
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`.
pub fn codex_sessions(codex_home: &Path, root: &Path) -> Vec<ExternalSession> {
let mut files = Vec::new();
rollout_files(&codex_home.join("sessions"), 4, &mut files);
let mut sessions: Vec<ExternalSession> =
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect();
if !sessions.is_empty() {
let names = codex_names(codex_home);
for s in &mut sessions {
if let Some(name) = names.get(&s.session_id) {
s.title = short(name);
}
}
}
sessions
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect()
}
#[cfg(test)]
@@ -312,16 +282,6 @@ mod tests {
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
assert_eq!(s.title, "add tests");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
write(
&tmp.path().join("session_index.jsonl"),
&[
json!({"id": "thread-1", "thread_name": "Add tests", "updated_at": "t1"}),
json!({"id": "thread-2", "thread_name": "Other", "updated_at": "t1"}),
json!({"id": "thread-1", "thread_name": "Add parser tests", "updated_at": "t2"}),
],
);
assert_eq!(codex_sessions(tmp.path(), Path::new("/work"))[0].title, "Add parser tests");
}
#[test]
-4
View File
@@ -1097,8 +1097,6 @@ impl AgentManager {
title_renamed: s.title_renamed,
}
};
// Copying may have started the source's agent: it stops again once idle.
source.release_when_idle();
let task = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), task.clone());
task.persist();
@@ -1168,8 +1166,6 @@ impl AgentManager {
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
// Undoing may have started the agent: it stops again once idle.
task.release_when_idle();
Ok(first)
}
@@ -158,42 +158,6 @@ pub(crate) fn title(entries: &[Value]) -> Option<String> {
.map(str::to_owned)
}
/// Claude Code titles a session from the last 1000 characters of its text.
const TITLE_SOURCE_CHARS: usize = 1000;
/// Below this Claude Code gives no title without asking the model.
const TITLE_SOURCE_MIN_CHARS: usize = 10;
/// The conversation's own text to title the session after: the prompts and the replies,
/// without the files attached to the prompts. `None` while there is too little.
pub(super) fn title_source(entries: &[Value]) -> Option<String> {
let mut text = String::new();
for e in chain(entries, None) {
if !(is_turn_start(e) || kind(e) == "assistant") {
continue;
}
let blocks = match e.pointer("/message/content") {
Some(Value::String(s)) => vec![s.as_str()],
Some(Value::Array(blocks)) => blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str))
.filter(|t| !t.starts_with("\n<context ref="))
.collect(),
_ => Vec::new(),
};
for block in blocks {
text.push_str(block);
text.push('\n');
}
}
let text = text.trim();
let len = text.chars().count();
if len < TITLE_SOURCE_MIN_CHARS {
return None;
}
Some(text.chars().skip(len.saturating_sub(TITLE_SOURCE_CHARS)).collect())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -255,29 +219,6 @@ mod tests {
assert_eq!(cut(&c, 1), Ok(None));
}
#[test]
fn titles_come_from_prompts_and_replies() {
let mut prompt = user("u1", None, "Fix the login form");
prompt["message"]["content"]
.as_array_mut()
.unwrap()
.push(json!({"type": "text", "text": "\n<context ref=\"file:///p/a.rs\">\nfn a() {}\n</context>"}));
let entries = vec![
prompt,
assistant(
"a1",
"u1",
json!([{"type": "thinking", "thinking": "hm"}, {"type": "text", "text": "Done."},
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]),
),
json!({"type": "user", "uuid": "r1", "parentUuid": "a1", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out"}]}}),
];
assert_eq!(title_source(&entries).as_deref(), Some("Fix the login form\nDone."));
assert_eq!(title_source(&[user("u1", None, "hi")]), None);
let long = "x".repeat(TITLE_SOURCE_CHARS + 5);
assert_eq!(title_source(&[user("u1", None, &long)]).map(|t| t.chars().count()), Some(TITLE_SOURCE_CHARS));
}
#[test]
fn project_directory_names() {
assert_eq!(escape(Path::new("/private/tmp/a.b/c_d")), "-private-tmp-a-b-c-d");
+10 -57
View File
@@ -191,8 +191,6 @@ struct State {
limit_resets_ms: Option<f64>,
terminal_only: Vec<String>,
titled: bool,
/// A title is being read or generated.
titling: bool,
}
struct Turn {
@@ -367,7 +365,6 @@ impl Shim {
limit_resets_ms: None,
terminal_only: Vec::new(),
titled: false,
titling: false,
}),
cwd,
starting: tokio::sync::Mutex::new(()),
@@ -586,12 +583,11 @@ impl Shim {
impl super::Shim for Shim {
fn attach(&self, client: Peer) {
let _ = self.client.set(client.clone());
// The client went away: so do the processes. Held until then: the connection drops
// its handler (this shim) as it closes, and the sessions would go without stopping.
let me = self.me.upgrade();
// The client went away: so do the processes.
let weak = self.me.clone();
tokio::spawn(async move {
client.closed().await;
if let Some(me) = me {
if let Some(me) = weak.upgrade() {
me.closed.store(true, Ordering::Release);
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
for s in sessions {
@@ -602,15 +598,6 @@ impl super::Shim for Shim {
}
}
impl Drop for Session {
/// A session dropped without being stopped (the shim went away) takes its process along.
fn drop(&mut self) {
if let Some(p) = self.st.get_mut().proc.take() {
p.kill.cancel();
}
}
}
impl Session {
fn shim(&self) -> Option<Arc<Shim>> {
self.shim.upgrade()
@@ -1061,8 +1048,7 @@ impl Session {
} else if matches!(st.start, Start::New { .. }) {
st.start = Start::Resume { id: self.id.clone(), at: None };
}
read_title = !st.titled && !st.titling;
st.titling |= read_title;
read_title = !st.titled;
if let Some(turn) = st.turn.as_ref()
&& belongs(turn, &msg)
{
@@ -1087,29 +1073,13 @@ impl Session {
}
}
/// Sends the session's title once it has one: the one in its transcript, or else one
/// Claude Code is asked for, as it titles no session run the way the Agent SDK runs it.
/// Without a title the next turn tries again.
/// Sends the title Claude Code gave the session, once it has one.
fn read_title(self: &Arc<Self>) {
let me = self.clone();
tokio::spawn(async move {
let reader = me.clone();
let entries = tokio::task::spawn_blocking(move || transcript_entries(&reader)).await.ok().flatten();
let mut title = entries.as_deref().and_then(history::title);
if title.is_none()
&& let Some(source) = entries.as_deref().and_then(history::title_source)
{
title = me.generate_title(source).await;
}
{
let mut st = me.st.lock();
st.titling = false;
if title.is_none() {
return;
}
st.titled = true;
}
if let (Some(title), Some(shim)) = (title, me.shim()) {
tokio::task::spawn_blocking(move || {
let Some(title) = chain_title(&me) else { return };
me.st.lock().titled = true;
if let Some(shim) = me.shim() {
shim.notify(vec![Update {
session: me.id.clone(),
update: json!({ "sessionUpdate": "session_info_update", "title": title }),
@@ -1118,19 +1088,6 @@ impl Session {
});
}
/// A title Claude Code generates from `source`, also written to the transcript.
async fn generate_title(&self, source: String) -> Option<String> {
let proc = self.st.lock().proc.clone()?;
let request = json!({ "subtype": "generate_session_title", "description": source, "persist": true });
match proc.control(request).await {
Ok(r) => r.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned),
Err(e) => {
tracing::debug!("generating a claude session title: {e}");
None
}
}
}
async fn on_cli_request(self: &Arc<Self>, proc: &Arc<Proc>, msg: Value) {
let id = msg.get("request_id").and_then(Value::as_str).unwrap_or_default().to_owned();
let request = msg.get("request").cloned().unwrap_or_default();
@@ -1460,13 +1417,9 @@ fn strings(v: Option<&Value>) -> Vec<String> {
/// The title in a session's transcript (or that of the session it continues).
fn chain_title(session: &Session) -> Option<String> {
history::title(&transcript_entries(session)?)
}
fn transcript_entries(session: &Session) -> Option<Vec<Value>> {
let shim = session.shim()?;
let path = shim.transcript(&session.cwd, &session.id).filter(|p| p.exists())?;
history::read(&path).ok()
history::title(&history::read(&path).ok()?)
}
fn start_json(start: &Start) -> Value {
+4 -76
View File
@@ -311,8 +311,6 @@ struct Replay {
sent: Arc<Mutex<Vec<Vec<Value>>>>,
launched: Mutex<Vec<Vec<String>>>,
errors: Arc<Mutex<Vec<String>>>,
/// Each process's kill token.
kills: Mutex<Vec<CancellationToken>>,
}
impl Cli for Replay {
@@ -333,7 +331,6 @@ impl Cli for Replay {
let (shim_in, cli_in) = tokio::io::duplex(1 << 20);
let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20);
let kill = CancellationToken::new();
self.kills.lock().push(kill.clone());
let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone());
sent.lock().push(Vec::new());
tokio::spawn(async move {
@@ -424,18 +421,14 @@ impl Replayed {
}
}
fn replay_cli(processes: Vec<Vec<Frame>>) -> Arc<Replay> {
Arc::new(Replay {
async fn replay(name: &str) -> Replayed {
let processes = load_fixture(name);
let cli = Arc::new(Replay {
processes: Mutex::new(processes.into()),
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
})
}
async fn replay(name: &str) -> Replayed {
let cli = replay_cli(load_fixture(name));
});
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
let h =
@@ -468,69 +461,6 @@ async fn replay_simple() {
assert_eq!(user["message"]["content"][0]["text"], "Reply with exactly: hello there");
}
/// The app closing the connection (an idle task released, archived or deleted) stops the
/// CLI processes of its sessions, idle ones included.
#[tokio::test(flavor = "multi_thread")]
async fn closing_the_connection_stops_the_processes() {
let r = replay("simple").await;
assert!(r.cli.kills.lock().iter().all(|k| !k.is_cancelled()));
r.h.client.kill();
let kills = r.cli.kills.lock().clone();
tokio::time::timeout(Duration::from_secs(5), async {
for k in kills {
k.cancelled().await;
}
})
.await
.expect("every process was stopped");
}
#[tokio::test(flavor = "multi_thread")]
async fn sessions_without_a_title_get_one_generated() {
let mut processes = load_fixture("simple");
processes[0].extend([
Frame::Shim(json!({"type": "control_request", "request_id": "req_3",
"request": {"subtype": "generate_session_title"}})),
Frame::Cli(json!({"type": "control_response",
"response": {"subtype": "success", "request_id": "req_3", "response": {"title": "Say hello"}}})),
]);
let cli = replay_cli(processes);
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
let h = Harness::connect(
cli.clone(),
config.path().into(),
config.path().join("pending"),
cwd_path.clone(),
Vec::new(),
)
.await;
let session = h.new_session().await;
// The transcript Claude Code writes, without the title it gives sessions in a terminal.
let dir = config.path().join("projects").join(history::escape(&cwd_path));
std::fs::create_dir_all(&dir).unwrap();
let prompt = json!({"type": "user", "uuid": "u1", "parentUuid": null,
"message": {"role": "user", "content": "Reply with exactly: hello there"}});
std::fs::write(dir.join(format!("{session}.jsonl")), format!("{prompt}\n")).unwrap();
h.prompt(&session, "Reply with exactly: hello there").await.unwrap();
let mut titles = Vec::new();
for _ in 0..100 {
titles = h.updates(&session, "session_info_update");
if !titles.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(cli.errors.lock().is_empty(), "{:?}", cli.errors.lock());
assert_eq!(titles.iter().map(|t| t["title"].clone()).collect::<Vec<_>>(), [json!("Say hello")]);
let sent = cli.sent.lock()[0].clone();
let request =
sent.iter().find(|m| m.pointer("/request/subtype") == Some(&json!("generate_session_title"))).unwrap();
assert_eq!(request["request"]["description"], "Reply with exactly: hello there");
assert_eq!(request["request"]["persist"], true);
}
#[tokio::test(flavor = "multi_thread")]
async fn replay_multi_turn() {
let r = replay("multi_turn").await;
@@ -797,7 +727,6 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
@@ -841,7 +770,6 @@ async fn a_failed_turn_reports_its_failure() {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h =
+4 -43
View File
@@ -28,9 +28,6 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
const EARLY_CANCEL: Duration = Duration::from_secs(1);
/// How long the client's connection outlives codex.
const EXIT_GRACE: Duration = Duration::from_millis(100);
/// What a thread's name is asked for with, after its first turn.
const NAME_PROMPT: &str = "Name this conversation for a list of tasks: a short title of at most six words, in the \
language the user writes in. Reply with the title only, without quotes or a closing period.";
pub(crate) fn connect(
spec: &ProviderSpec,
@@ -129,10 +126,6 @@ struct Thread {
early_cancel: Option<Instant>,
/// The latest agent message (a subagent's answer).
answer: String,
/// The thread has a name.
named: bool,
/// A name is being asked for.
naming: bool,
}
struct PendingPrompt {
@@ -336,7 +329,6 @@ impl CodexShim {
t.parent = None;
t.settings = Some(settings);
t.cwd = cwd;
t.named = thread.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty());
(id, options)
}
@@ -633,9 +625,8 @@ impl CodexShim {
Some(translate::plan(params))
}),
"thread/tokenUsage/updated" => self.with_thread(thread, |_, _| translate::usage(params)),
"thread/name/updated" => self.with_thread(thread, |t, _| {
"thread/name/updated" => self.with_thread(thread, |_, _| {
let title = params.get("threadName").and_then(Value::as_str)?;
t.named = true;
Some(json!({ "sessionUpdate": "session_info_update", "title": title }))
}),
"thread/started" => self.thread_started(params.get("thread").unwrap_or(&Value::Null)),
@@ -821,10 +812,9 @@ impl CodexShim {
}
}
fn turn_completed(self: &Arc<Self>, thread: &str, turn: &Value) {
fn turn_completed(&self, thread: &str, turn: &Value) {
let id = turn.get("id").and_then(Value::as_str).unwrap_or_default();
let completed = turn.get("status").and_then(Value::as_str) == Some("completed");
let (parent, answer, name) = {
let (parent, answer) = {
let mut state = self.state.lock();
let Some(t) = state.threads.get_mut(thread) else { return };
if t.turn.as_deref().is_none_or(|r| r == id) {
@@ -840,15 +830,8 @@ impl CodexShim {
let _ = p.done.send((turn.clone(), error.clone()));
}
let answer = std::mem::take(&mut t.answer);
let name =
completed && !id.is_empty() && t.parent.is_none() && t.settings.is_some() && !t.named && !t.naming;
t.naming |= name;
(t.parent.clone(), (answer, error), name)
(t.parent.clone(), (answer, error))
};
if name {
let (shim, thread, turn) = (self.clone(), thread.to_owned(), id.to_owned());
self.runtime.spawn(async move { shim.name_thread(&thread, &turn).await });
}
let Some(parent) = parent else { return };
let (answer, error) = answer;
let mut state_of = match turn.get("status").and_then(Value::as_str) {
@@ -870,28 +853,6 @@ impl CodexShim {
}
}
/// Names a thread from what it holds after `turn`, as codex leaves naming to its
/// clients; `thread/name/updated` brings the name back. Unnamed, the next turn tries again.
async fn name_thread(&self, thread: &str, turn: &str) {
let sample = json!({ "threadId": thread, "expectedTurnId": turn, "prompt": NAME_PROMPT, "effort": "low" });
let named = match self.call("thread/sample", sample).await {
Ok(r) => match r.get("text").and_then(Value::as_str).and_then(translate::thread_name) {
Some(name) => {
self.call("thread/name/set", json!({ "threadId": thread, "name": name })).await.map(|_| ())
}
None => Err(error(codes::INTERNAL_ERROR, "codex gave no name")),
},
Err(e) => Err(e),
};
if let Err(e) = &named {
tracing::debug!("naming codex thread {thread}: {}", e.message);
}
if let Some(t) = self.state.lock().threads.get_mut(thread) {
t.naming = false;
t.named |= named.is_ok();
}
}
/// Requests from codex: approvals and questions, asked as ACP permission requests.
fn on_codex_request(&self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default().to_owned();
+5 -50
View File
@@ -535,10 +535,8 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
// Older recordings predate skill discovery and thread naming; play an
// unsupported server.
let method = actual["method"].as_str().unwrap_or_default();
if matches!(method, "skills/list" | "thread/sample") && frame["method"] != method {
// Older recordings predate skill discovery; play an unsupported server.
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
continue;
@@ -576,14 +574,7 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
/// Runs `scenario` against its transcript; returns the ACP results, the client and
/// the frames the shim sent.
async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value) {
let (results, client, sent, meta, _codex) = replay_transcript(scenario, load(scenario.file())).await;
(results, client, sent, meta)
}
/// Both connections of a replay, which stay open while it is held.
type CodexEnd = Box<dyn std::any::Any>;
async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<Client>, Vec<Value>, Value, CodexEnd) {
let t = load(scenario.file());
let (ours, theirs) = tokio::io::duplex(1 << 20);
let (tr, tw) = tokio::io::split(theirs);
let client = Arc::new(Client::default());
@@ -599,8 +590,8 @@ async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<C
.await
.expect("the scenario finished");
// Codex stays up until the scenario is over.
let (sent, read, write) = player.await.unwrap();
(results, client, sent, meta, Box::new((acp, read, write)))
let (sent, _read, _write) = player.await.unwrap();
(results, client, sent, meta)
}
fn sent<'a>(frames: &'a [Value], method: &str) -> Vec<&'a Value> {
@@ -810,42 +801,6 @@ async fn a_simple_turn_streams_reasoning_text_and_usage() {
assert_eq!(info["title"], "Say hi");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unnamed_thread_is_named_after_its_first_turn() {
let mut t = load(Scenario::Simple.file());
t.frames.retain(|(_, f)| f["method"] != "thread/name/updated");
let thread = "01a18300-0000-7000-8000-000000000001";
t.frames.extend([
(true, json!({"id": 90, "method": "thread/sample"})),
(false, json!({"id": 90, "result": {"responseId": "r", "sourceTurnId": thread, "text": "\"Say hi.\"\n"}})),
(true, json!({"id": 91, "method": "thread/name/set"})),
(false, json!({"id": 91, "result": {}})),
(false, json!({"method": "thread/name/updated", "params": {"threadId": thread, "threadName": "Say hi"}})),
]);
let (results, client, frames, _, _codex) = replay_transcript(Scenario::Simple, t).await;
let session = session_of(&results, "new");
let sample = &sent(&frames, "thread/sample")[0]["params"];
assert_eq!((sample["threadId"].as_str(), sample["expectedTurnId"].as_str()), (Some(thread), Some(thread)));
assert_eq!(sent(&frames, "thread/name/set")[0]["params"], json!({"threadId": thread, "name": "Say hi"}));
let mut info = Vec::new();
for _ in 0..100 {
info = client.of(&session).into_iter().filter(|u| u["sessionUpdate"] == "session_info_update").collect();
if !info.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(info.iter().map(|u| u["title"].clone()).collect::<Vec<_>>(), [json!("Say hi")]);
}
#[test]
fn thread_names_are_the_first_line_of_the_reply() {
assert_eq!(thread_name(" \n“修复登录表单。”\nmore").as_deref(), Some("修复登录表单"));
assert_eq!(thread_name("**Fix the login form.**").as_deref(), Some("Fix the login form"));
assert_eq!(thread_name(" \n\"\" "), None);
assert_eq!(thread_name(&"x".repeat(100)).map(|n| n.chars().count()), Some(80));
}
#[tokio::test(flavor = "multi_thread")]
async fn settings_apply_to_the_next_turn() {
let (results, client, frames, _) = replay(Scenario::MultiTurn).await;
@@ -612,16 +612,6 @@ pub(super) fn usage(params: &Value) -> Option<Value> {
Some(json!({ "sessionUpdate": "usage_update", "used": used, "size": size }))
}
/// A thread name from the model's reply to the naming prompt: its first line, without
/// quotes or a closing period. `None` when nothing is left.
pub(super) fn thread_name(reply: &str) -> Option<String> {
const MAX_NAME: usize = 80;
let line = reply.lines().map(str::trim).find(|l| !l.is_empty())?;
let line = line.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '“' | '”' | '「' | '」' | '*'));
let line = line.trim_end_matches(['.', '。']).trim();
(!line.is_empty()).then(|| line.chars().take(MAX_NAME).collect())
}
/// How a failed turn is reported.
#[derive(Debug, PartialEq)]
pub(super) enum Failure {
-21
View File
@@ -2391,27 +2391,6 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
assert!(live(&mgr, &task.id));
mgr.shutdown();
// Undoing turns or copying the conversation starts a stopped agent: it stops again too.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
mgr.rollback(&task.id, 1).await.unwrap();
assert!(live(&mgr, &task.id));
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id), "an agent started to undo turns stops too");
mgr.fork(&task.id, None).await.unwrap();
assert!(live(&mgr, &task.id));
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id), "an agent started to copy the conversation stops too");
mgr.shutdown();
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
+24 -79
View File
@@ -225,15 +225,6 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
/// The bytes of `path` (`Some(None)`: no file there), or `None` when it is there but can't be read.
fn existing(path: &Path) -> Option<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Some(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(None),
Err(_) => None,
}
}
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() {
@@ -420,31 +411,25 @@ pub(crate) fn accept(
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
// The task's version of one path of the checkout (`Some(None)`: absent there), or `None` when
// the checkout changed it itself.
let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> {
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let local = local_root.join(rel);
let in_task = repo.root().join(rel);
let current = std::fs::read(&in_task).ok();
// A file there that can't be read is the checkout's own: never written over.
let Some(local_bytes) = existing(&local_root.join(rel)) else { return Ok(None) };
if local_bytes == current {
return Ok(Some(current));
}
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
let current = std::fs::read(&in_task).ok();
let local_bytes = std::fs::read(&local).ok();
if local_bytes == current {
return Ok(true);
}
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base,
};
Ok(unchanged.then_some(current))
};
// Puts `bytes` (`None`: nothing) at `rel` of the checkout.
let write = |rel: &str, bytes: Option<Vec<u8>>| -> IdeResult<()> {
let local = local_root.join(rel);
if std::fs::read(&local).ok() == bytes {
return Ok(());
if !unchanged {
return Ok(false);
}
match bytes {
match current {
Some(bytes) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
@@ -456,12 +441,6 @@ pub(crate) fn accept(
_ => {}
},
}
Ok(())
};
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let Some(bytes) = target(rel)? else { return Ok(false) };
write(rel, bytes)?;
Ok(true)
};
let mut accepted = Vec::with_capacity(changes.len());
@@ -487,9 +466,8 @@ pub(crate) fn accept(
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
// spellings to one file it is renamed in place and `apply` then refreshes
// the content. On a case-sensitive disk the two names are different files:
// both are checked before either changes — the old one must be the file the
// checkout had, the new one absent or already the task's — then the task's
// version lands at the new name before the old one goes.
// the old one's unchanged-ness (it is the file the checkout had) decides —
// then the task's version lands at the new name and the old one goes.
let case_only =
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
if case_only {
@@ -498,18 +476,20 @@ pub(crate) fn accept(
if same_file(&old_path, &new_path) {
std::fs::rename(&old_path, &new_path)?;
} else {
let Some(old_target) = target(old)? else {
if !apply(old)? {
skipped.push(skip(old));
continue;
};
let task_bytes = std::fs::read(repo.root().join(&change.rel_path)).ok();
let at_new = existing(&new_path);
if at_new.as_ref().is_none_or(|at_new| at_new.is_some() && *at_new != task_bytes) {
skipped.push(skip(&change.rel_path));
continue;
}
write(&change.rel_path, task_bytes)?;
write(old, old_target)?;
// `apply(old)` removed the old file (the task's tree has it only at
// the new name); now write that version under the new name. `apply`
// can't do it — it reads the new name's own base/absence — so the
// write is open-coded from the same task bytes `apply` would use.
if let Ok(bytes) = std::fs::read(repo.root().join(&change.rel_path)) {
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&new_path, bytes)?;
}
accepted.push(change.rel_path.clone());
continue;
}
@@ -980,41 +960,6 @@ mod tests {
assert_eq!(names, ["A.txt"]);
}
#[test]
fn a_case_only_rename_keeps_a_file_the_checkout_has_at_the_new_name() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(repo.join("A.txt"), "mine\n").unwrap();
if std::fs::read_to_string(repo.join("a.txt")).unwrap() == "mine\n" {
eprintln!("this disk doesn't tell a.txt from A.txt; nothing to check");
return;
}
git(&created.path, &["mv", "a.txt", "A.txt"]);
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["A.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("A.txt")).unwrap(), "mine\n");
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\ntwo\n");
}
#[cfg(unix)]
#[test]
fn accept_leaves_a_checkout_file_it_cannot_read() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("new.txt"), "task\n").unwrap();
std::fs::write(repo.join("new.txt"), "mine\n").unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o200)).unwrap();
if std::fs::read(repo.join("new.txt")).is_ok() {
eprintln!("running as a user that reads any file; nothing to check");
return;
}
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["new.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "mine\n");
}
#[cfg(windows)]
#[test]
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
+1 -2
View File
@@ -12,8 +12,7 @@ mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use launch_resources::process_launch_resources;
pub use single::{
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
single_file_target,
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
};
pub use up_to_date::{menu_up_to_date, record_menu_build};
+4 -23
View File
@@ -144,13 +144,6 @@ fn bracket_pos(s: &str) -> Option<(u32, Option<u32>, &str)> {
Some((line, col, rest.trim_start()))
}
/// `/C:/p/A.java` → `C:/p/A.java`: Maven and file URIs put a `/` before the drive letter on Windows.
/// Joined as is, it would become the drive-relative `C:p/A.java`.
fn drive_path(p: &str) -> &str {
let b = p.as_bytes();
if b.len() > 2 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { &p[1..] } else { p }
}
/// `file:///p/A.kt` → `/p/A.kt` (percent-decoded).
fn from_file_uri(s: &str) -> String {
let Some(rest) = s.strip_prefix("file://") else { return s.to_owned() };
@@ -169,7 +162,9 @@ fn from_file_uri(s: &str) -> String {
out.push(bytes[i]);
i += 1;
}
drive_path(&String::from_utf8_lossy(&out)).to_owned()
let path = String::from_utf8_lossy(&out).into_owned();
// `file:///C:/x` on Windows.
if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' { path[1..].to_owned() } else { path }
}
/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix.
@@ -227,7 +222,7 @@ fn maven_line(line: &str) -> Option<RawMessage> {
&& looks_like_source_path(path)
&& let Some((ln, col, msg)) = bracket_pos(&format!("[{pos}"))
{
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(drive_path(path), ln, col));
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(path, ln, col));
}
if let Some(m) = kotlin_line(rest) {
return Some(m);
@@ -461,20 +456,6 @@ mod tests {
assert_eq!(out[1].unit, ColUnit::Char);
}
#[test]
fn maven_windows_paths_drop_the_slash_before_the_drive() {
let out = parse(
Format::Maven,
"[ERROR] /C:/dev/p/src/main/java/a/A.java:[18,41] package a.b does not exist\n\
[ERROR] C:\\dev\\p\\B.java:[3,8] cannot find symbol\n\
[ERROR] file:///C:/dev/p/K.kt:4:9 Unresolved reference 'x'.\n",
);
assert_eq!(out.len(), 3, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:/dev/p/src/main/java/a/A.java", 18, Some(41)));
assert_eq!(pos(&out[1]), (Severity::Error, "C:\\dev\\p\\B.java", 3, Some(8)));
assert_eq!(pos(&out[2]), (Severity::Error, "C:/dev/p/K.kt", 4, Some(9)));
}
#[test]
fn maven_detail_lines_with_level_prefix_and_kotlin() {
let out = parse(
+3 -210
View File
@@ -4,21 +4,12 @@
//! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble.
use super::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module.
const CP_FILE: &str = "target/ide-compile-cp.txt";
/// The resolved dependency classpath for one module and scope, tied to the same
/// inputs as its launch build so POM, JDK, settings and profile changes invalidate it.
#[derive(Debug, Deserialize, Serialize)]
struct CachedClasspath {
fingerprint: LaunchFingerprint,
classpath: String,
}
/// A `.java` source and the module build that compiles it.
pub struct SingleFileTarget {
/// Absolute module directory.
@@ -73,28 +64,6 @@ pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeRe
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// The Maven reactor target for a launch module whose source file is not known yet.
fn module_target(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<SingleFileTarget> {
let Some(m) = &model.maven else {
return Err(IdeError::not_found("no linked Maven project found for module"));
};
let module = if module.is_absolute() { module.to_path_buf() } else { root.join(module) };
let project = m
.projects
.iter()
.find(|p| root.join(&p.dir) == module)
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
if project.packaging == "pom" {
return Err(IdeError::not_found("the launch module is a Maven aggregator without sources"));
}
let top = maven_reactor(m, &project.dir)
.ok_or_else(|| IdeError::not_found("no linked Maven reactor found for module"))?;
let top_dir = subdir(root, &top.dir);
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
let home = env.get("JAVA_HOME").map(Path::new);
@@ -109,88 +78,6 @@ pub fn find_javac(env: &BTreeMap<String, String>) -> String {
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
classpath(root, model, target, "test", false)
}
/// Runtime (or test) dependency classpath for a launch module.
///
/// The Java language server runs on its own JDK, so its Maven model can select a
/// different JDK profile and dependency classifier than the project launch. Use the
/// same Maven process that builds the module instead.
pub fn module_classpath(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cache: &Path,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
let target = module_target(root, model, module, test)?;
let fingerprint = launch_fingerprint(root, model, &target.module, test, local_repo, cancel)?;
let cache_path = classpath_cache_path(root, cache, &target.module, test);
if let (Some(fingerprint), Some(path)) = (&fingerprint, &cache_path)
&& let Ok(bytes) = fs::read(path)
&& let Ok(cached) = serde_json::from_slice::<CachedClasspath>(&bytes)
&& cached.fingerprint == *fingerprint
&& classpath_entries_exist(root, &cached.classpath)
{
return Ok(cached.classpath);
}
let classpath = classpath(root, model, &target, if test { "test" } else { "runtime" }, true)?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let (Some(fingerprint), Some(path)) = (fingerprint, cache_path) {
let cached = CachedClasspath { fingerprint, classpath: classpath.clone() };
if let Err(error) = cache_classpath(&path, &cached) {
tracing::debug!(path = %path.display(), %error, "Cannot cache the Java launch classpath");
}
}
Ok(classpath)
}
/// One cache file per module and scope. Mirroring the module path keeps nested
/// modules and their different dependency scopes from overwriting each other.
fn classpath_cache_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
let relative = module.strip_prefix(root).ok()?;
let mut path = cache.join("java-launch/classpaths");
path.push(if relative.as_os_str().is_empty() { Path::new("_root") } else { relative });
path.push(if test { "test.json" } else { "runtime.json" });
Some(path)
}
/// Best-effort cache persistence. A failed write only costs one later Maven
/// probe, so the launch still succeeds even when the cache directory is unusable.
fn cache_classpath(path: &Path, cached: &CachedClasspath) -> IdeResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_vec(cached).map_err(|e| IdeError::internal(e.to_string()))?)?;
Ok(())
}
/// A cache entry stays valid while every file exists. Maven also emits reactor
/// output directories for modules with no classes, which may legitimately never be
/// created; the launch snapshot already supplies the module's classes in that case.
fn classpath_entries_exist(root: &Path, classpath: &str) -> bool {
// Temp directories on macOS are commonly reached through `/var`, while Maven
// reports the canonical `/private/var` path. Compare against both spellings.
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
std::env::split_paths(classpath).all(|entry| {
entry.exists()
|| ((entry.starts_with(root) || entry.starts_with(&canonical_root))
&& (entry.ends_with(Path::new("target/classes")) || entry.ends_with(Path::new("target/test-classes"))))
})
}
fn classpath(
root: &Path,
model: &BuildModel,
target: &SingleFileTarget,
scope: &str,
compile_first: bool,
) -> IdeResult<String> {
let mut entries: Vec<String> = Vec::new();
// The module's own classes first: the file almost always uses classes next to it.
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
@@ -223,14 +110,7 @@ fn classpath(
args.extend(["-pl", target.pl.as_str(), "-am"]);
}
let output_arg = format!("-Dmdep.outputFile={probe}");
let scope_arg = format!("-Dmdep.includeScope={scope}");
// A launch build may only have reached `compile`. Running `compile` in this
// same Maven session lets the dependency plugin resolve Reactor siblings from
// their target/classes without paying for package-phase plugins.
if compile_first {
args.push("compile");
}
args.extend(["dependency:build-classpath", output_arg.as_str(), scope_arg.as_str(), "-q"]);
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command)
.args(&args)
@@ -239,10 +119,8 @@ fn classpath(
.output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() {
let stdout = String::from_utf8_lossy(&status.stdout);
let stderr = String::from_utf8_lossy(&status.stderr);
// Maven reports reactor/dependency failures on stdout, not only stderr.
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
}
@@ -427,7 +305,7 @@ mod tests {
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
@@ -449,90 +327,5 @@ mod tests {
assert!(cp.contains("/repo/a.jar"), "{cp}");
assert!(cp.contains("/repo/b.jar"), "{cp}");
assert!(cp.split(separator).count() >= 3, "{cp}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(!args.lines().any(|a| a == "compile" || a == "package"), "{args}");
}
/// A launch uses runtime dependencies, while tests additionally see test-scope ones.
#[cfg(unix)]
#[test]
fn launch_module_classpath_uses_the_matching_dependency_scope() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/runtime.jar:/repo/test.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let model = crate::load(root, &root.join("cache"), None);
let cache = root.join("cache");
let runtime =
module_classpath(root, &model, &root.join("app"), false, None, &cache, &CancellationToken::new()).unwrap();
assert!(runtime.contains("/repo/runtime.jar"), "{runtime}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "compile"), "{args}");
assert!(!args.lines().any(|a| a == "package"), "{args}");
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=runtime"), "{args}");
let test =
module_classpath(root, &model, &root.join("app"), true, None, &cache, &CancellationToken::new()).unwrap();
assert!(test.contains("/repo/test.jar"), "{test}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=test"), "{args}");
}
/// The launch classpath follows the Maven runner JDK and is reused until that
/// build input changes; the probe does not run packaging plugins.
#[cfg(unix)]
#[test]
fn launch_classpath_cache_follows_the_runner_jdk() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
// `dependency:build-classpath` reports reactor outputs as absolute paths. Keep
// the fake probe faithful so the cache's existence check sees the output.
fs::write(
&script,
"#!/bin/sh\nprintf x >> calls.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target/classes\" \"$JAVA_HOME/classpath\"\n: > \"$JAVA_HOME/classpath/jdk.jar\"\nprintf '%s/classpath/jdk.jar:%s/%s/target/classes:%s/empty/target/classes' \"$JAVA_HOME\" \"$(pwd)\" \"$pl\" \"$(pwd)\" > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let mut model = crate::load(root, &root.join("cache"), None);
for (name, version) in [("jdk21", "21"), ("jdk8", "8")] {
let home = root.join(name);
fs::create_dir_all(home.join("bin")).unwrap();
fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
fs::write(home.join("bin/java"), "").unwrap();
}
let cache = root.join("classpath-cache");
let cancel = CancellationToken::new();
let jdk21 = root.join("jdk21");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk21.to_string_lossy().into_owned());
}
let first = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(first.contains("jdk21/classpath/jdk.jar"), "{first}");
assert!(!first.contains("jdk8/classpath/jdk.jar"), "{first}");
let second = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert_eq!(second, first);
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "x");
let jdk8 = root.join("jdk8");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk8.to_string_lossy().into_owned());
}
let switched = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(switched.contains("jdk8/classpath/jdk.jar"), "{switched}");
assert!(!switched.contains("jdk21/classpath/jdk.jar"), "{switched}");
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "xx");
}
}
+21 -206
View File
@@ -712,27 +712,18 @@ pub(crate) fn launch_inputs(
// Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation {
// A source root under Maven's build directory is generated by the build itself.
// Skip it without resolving the property: `<build><directory>` may be custom, and
// hashing its rewritten files would make every launch look changed.
if !(input && el.text.contains("${project.build.directory}")) {
// Other properties can be overridden by -D, profiles or settings. Without
// Maven's effective model, leave them unresolved to conservatively disable reuse.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
let path = dir.join(value);
// Conventional generated sources under target are also build outputs.
if !(input && path.starts_with(dir.join("target"))) {
out.push(path);
}
}
// Other properties can be overridden by -D, profiles or settings.
// Without Maven's effective roots, conservatively disable reuse for them.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
out.push(dir.join(value));
}
}
for child in &el.children {
@@ -1689,15 +1680,6 @@ pub enum PomGotoKind {
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
/// The POM of the artifact itself: a project module's, else the local repository's.
ArtifactPom,
}
/// A Maven project of the workspace, for going to a dependency on it.
pub struct ModulePom {
pub group_id: String,
pub artifact_id: String,
pub pom: PathBuf,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1777,20 +1759,6 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
/// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
goto_declaration_in(text, path, pos, local_repo, Vec::new)
}
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
/// management entry provides (or a management entry itself) go to the artifact's own
/// POM: the project module among `modules` with its coordinates, else the local
/// repository's POM of its version.
pub fn goto_declaration_in(
text: &str,
path: &Path,
pos: Pos,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1801,56 +1769,15 @@ pub fn goto_declaration_in(
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
if elements.get(1).is_some_and(|e| e.name == "parent") {
let chain = parent_chain(path, text, local_repo);
let (pom, parent_text) = chain.get(1)?;
let label = elements[1].text_of("artifactId").unwrap_or_default();
return Some(pom_goto(pom.clone(), parent_text, label));
}
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
// A management entry declares the version: it goes to the artifact (a BOM import, say).
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
return Some(found);
}
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
}
artifact_target(text, path, e, local_repo, modules)
}
/// The artifact's own POM for a dependency or plugin element `e`.
fn artifact_target(
text: &str,
path: &Path,
e: &Element,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let props = properties_at(&mut resolver, &chain, 0);
let (group, artifact) = coordinates(e, &props)?;
let label = format!("{group}:{artifact}");
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
let module_text = xml::read_text(&module.pom)?;
return Some(pom_goto(module.pom, &module_text, label));
}
let version = interpolate(&e.text_of("version")?, &props);
let pom = resolver.repo_pom(&group, &artifact, &version)?;
let pom_text = xml::read_text(&pom)?;
Some(pom_goto(pom, &pom_text, label))
}
/// The POM at `path` as a target, at its `<artifactId>`.
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
let range = xml::parse(text)
.filter(|e| e.name == "project")
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
.unwrap_or_default();
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -2149,52 +2076,6 @@ mod tests {
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
#[test]
fn launch_inputs_skip_generated_sources_under_the_project_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>\
<packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><build><plugins><plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId>
<executions><execution><configuration><sources>
<source>${project.build.directory}/generated-sources/java</source>
</sources></configuration></execution></executions>
</plugin></plugins></build></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
let inputs = launch_inputs(root, &projects, None).expect("portable launch inputs");
assert!(!inputs.contains(&root.join("app/target/generated-sources/java")), "{inputs:?}");
assert!(inputs.contains(&root.join("app/src")), "conventional sources stay fingerprinted");
}
#[test]
fn launch_inputs_disable_reuse_for_file_activation_on_the_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version>
<build><directory>${project.basedir}/custom-build</directory></build>
<profiles><profile><id>generated</id><activation><file>
<exists>${project.build.directory}/enable-profile</exists>
</file></activation></profile></profiles></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
assert!(launch_inputs(root, &projects, None).is_none());
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -3206,9 +3087,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version and no local repository has its POM.
// The dependency names its own version: it is the declaration.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry declares the version; its artifact's POM is not at hand either.
// A management entry is a declaration itself, not a reference.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
@@ -3220,72 +3101,6 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
std::fs::create_dir_all(&bom_dir).unwrap();
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${{alibaba.version}}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let core = tmp.path().join("core/pom.xml");
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
std::fs::write(
&core,
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
)
.unwrap();
let modules =
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
// A dependency on a project module opens the module's POM.
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto =
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
// The <parent> section opens the parent POM.
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
assert_eq!(goto.path, canon(&parent_path));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
+7 -24
View File
@@ -393,13 +393,11 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
out
}
/// The repository directory of `coordinates` (a group id, or a group and an artifact
/// id): the group's dots are directories, an artifact id is one directory whatever dots
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
/// against the layout's alphabet so POM text cannot walk outside `repo`.
/// The repository directory of the `/`-separated `coordinates` (a group id, or
/// a group and an artifact id), each segment validated against the layout's
/// alphabet so POM text cannot walk outside `repo`.
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
let (group, artifact) = coordinates.split_first()?;
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect();
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
}
@@ -462,7 +460,6 @@ mod tests {
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
"org/example/lib/2.0/lib-2.0.pom",
"net/nu/netty/4.1.100/netty-4.1.100.jar",
"org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar",
// Failed downloads (a version typed while editing): tracking files only, not installed.
"org/example/lib/2/lib-2.jar.lastUpdated",
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
@@ -573,10 +570,9 @@ mod tests {
};
// Only directories holding artifacts are group ids: `net` and `org`
// alone never receive an install, their deeper levels do.
let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
assert_eq!(options(""), [&["net.nu"][..], &org].concat());
assert_eq!(options("org"), org);
assert_eq!(options("org."), org);
assert_eq!(options(""), ["net.nu", "org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org.spr"), Vec::<String>::new());
assert_eq!(options("net."), ["net.nu"]);
}
@@ -627,19 +623,6 @@ mod tests {
};
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
// An artifact id with dots is one directory.
let dotted = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::Version,
group: Some("org.eclipse.platform".into()),
artifact: Some("org.eclipse.core.runtime".into()),
},
);
assert_eq!(dotted.into_iter().map(|o| o.label).collect::<Vec<_>>(), ["3.31.0"]);
}
#[test]
-9
View File
@@ -270,13 +270,6 @@ fn run(
/// The first error lines of a failed build, for the tool window.
pub(crate) fn error_summary(text: &str) -> String {
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
in Settings › Build, Execution, Deployment › Gradle."
.to_owned();
}
let lines: Vec<&str> = text
.lines()
.filter(|l| {
@@ -916,8 +909,6 @@ mod tests {
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
assert_eq!(error_summary(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
}
#[test]
-15
View File
@@ -27,13 +27,6 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
#[cfg(target_os = "macos")]
pub fn text() -> Option<String> {
imp::text()
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -380,14 +373,6 @@ mod imp {
})
}
pub fn text() -> Option<String> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
-3
View File
@@ -270,8 +270,6 @@ pub struct SmartKeysSettings {
pub pair_quotes: bool,
pub surround_selection: bool,
pub tab_out_of_pair: bool,
/// Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag.
pub simultaneous_tag_editing: bool,
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub escape_on_paste: bool,
@@ -291,7 +289,6 @@ impl Default for SmartKeysSettings {
pair_quotes: true,
surround_selection: true,
tab_out_of_pair: true,
simultaneous_tag_editing: true,
reformat_on_paste: "indentEachLine".into(),
escape_on_paste: true,
smart_indent: true,
-8
View File
@@ -8,18 +8,13 @@
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::sync::Arc;
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
/// started or failed to.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
@@ -77,9 +72,6 @@ pub enum IdeEvent {
WorkspaceEditApplied {
id: u32,
paths: Vec<crate::IpcPath>,
/// Made by an agent through the MCP server's tools: undoable like a refactoring, but
/// not announced as one.
by_agent: bool,
},
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
+1 -52
View File
@@ -76,8 +76,7 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
text
}
/// The `encoding_rs` encoding of a Windows code page, when it has one.
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
@@ -102,56 +101,6 @@ pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding>
})
}
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
pub fn console_code_page() -> Option<u32> {
#[cfg(windows)]
{
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
(cp != 65001).then_some(cp)
}
#[cfg(not(windows))]
{
None
}
}
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
/// piece by piece.
#[cfg(windows)]
pub fn is_single_byte_code_page(cp: u32) -> bool {
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
unsafe {
let mut info: CPINFO = std::mem::zeroed();
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
}
}
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
/// like the OEM pages 437, 850 or 852).
#[cfg(windows)]
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
use windows_sys::Win32::Globalization::MultiByteToWideChar;
let Ok(len) = i32::try_from(bytes.len()) else {
return String::from_utf8_lossy(bytes).into_owned();
};
if len == 0 {
return String::new();
}
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::from_utf8_lossy(bytes).into_owned();
}
let mut wide = vec![0u16; needed as usize];
// SAFETY: `wide` holds `needed` units.
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
String::from_utf16_lossy(&wide[..written.max(0) as usize])
}
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
-13
View File
@@ -77,8 +77,6 @@ pub enum AutoUpload {
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentSettings {
pub servers: Vec<DeploymentServer>,
/// Server groups: Upload to… on a group uploads to each of its servers.
pub groups: Vec<DeploymentGroup>,
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
pub default_server: String,
pub auto_upload: AutoUpload,
@@ -94,7 +92,6 @@ impl Default for DeploymentSettings {
fn default() -> Self {
Self {
servers: Vec::new(),
groups: Vec::new(),
default_server: String::new(),
auto_upload: AutoUpload::Never,
delete_remote: false,
@@ -106,16 +103,6 @@ impl Default for DeploymentSettings {
}
}
/// Named set of deployment servers that files are uploaded to together.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentGroup {
pub id: String,
pub name: String,
/// [`DeploymentServer::id`]s, in upload order.
pub servers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DeploymentType {
+2 -60
View File
@@ -288,49 +288,14 @@ pub enum NewTaskApprovals {
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on [`Self::address`] while the IDE runs.
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
pub run_without_confirmation: bool,
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
/// address of this computer, or `0.0.0.0` / `::` for every address.
pub address: String,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
/// The port of [`Self::fixed_port`].
pub port: u16,
/// Keep the bearer token across runs (in the app data, readable by this user only) rather than
/// make a new one each run.
pub fixed_token: bool,
}
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
/// The default of [`McpServerSettings::address`]: this computer only.
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
impl Default for McpServerSettings {
fn default() -> Self {
Self {
enabled: false,
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
address: DEFAULT_MCP_ADDRESS.to_owned(),
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
}
}
}
/// Absent profiles retain automatic discovery for existing projects.
@@ -679,28 +644,6 @@ pub struct AppearanceSettings {
pub status_bar_widgets: BTreeMap<String, bool>,
/// Customize Main Toolbar; `None` = the default toolbar.
pub main_toolbar: Option<MainToolbarSettings>,
/// Appearance › Background Image: an image shown through the IDE window.
pub background_image: BackgroundImageSettings,
}
/// Appearance › Background Image. An empty `path` shows no image.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct BackgroundImageSettings {
pub path: String,
/// Percent, 0–100.
pub opacity: u8,
/// `scale` (cover the window) | `plain` (original size) | `tile`.
pub fill: String,
/// Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`,
/// `bottom-left`, `bottom`, `bottom-right`.
pub anchor: String,
}
impl Default for BackgroundImageSettings {
fn default() -> Self {
Self { path: String::new(), opacity: 15, fill: "scale".into(), anchor: "center".into() }
}
}
/// Main toolbar entries per side, in order: title widget ids (`project`,
@@ -771,7 +714,6 @@ impl Default for AppearanceSettings {
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
background_image: BackgroundImageSettings::default(),
}
}
}
-3
View File
@@ -28,9 +28,6 @@ tracing.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
+12 -195
View File
@@ -1,7 +1,6 @@
//! Attach to Process: the current user's processes, grouped by the debugger that can
//! attach to them (IDEA's `XAttachDebuggerProvider`s): JVMs with a JDWP agent, Node,
//! Python (debugpy injection), .NET (netcoredbg), Go binaries (Delve) and anything else
//! natively (LLDB).
//! Python (debugpy injection), Go binaries (Delve) and anything else natively (LLDB).
use crate::adapter;
use ide_core::{IdeError, IdeResult};
@@ -15,7 +14,6 @@ pub enum AttachKind {
Java,
Node,
Python,
Dotnet,
Go,
Native,
}
@@ -97,29 +95,13 @@ fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}
/// A .NET app: the `dotnet` host, or an apphost next to its `<name>.runtimeconfig.json`.
pub fn is_dotnet(exe: &str) -> bool {
let path = Path::new(exe);
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return false };
if stem == "dotnet" {
return true;
}
path.is_absolute() && path.with_file_name(format!("{stem}.runtimeconfig.json")).is_file()
}
fn is_python(name: &str) -> bool {
let n = name.strip_suffix(".exe").unwrap_or(name);
n == "Python" || n.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
}
/// Classifies a process; `is_dotnet` and `is_go` are asked only for processes no
/// interpreter claims.
pub fn classify(
p: &RawProcess,
is_dotnet: impl Fn(&str) -> bool,
is_go: impl Fn(&str) -> bool,
native_adapter: &str,
) -> Option<AttachableProcess> {
/// Classifies a process; `is_go` is asked only for processes no interpreter claims.
pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &str) -> Option<AttachableProcess> {
let name = file_name(&p.exe).to_owned();
let make = |kind, adapter: &str, addr: Option<(Option<String>, u16)>| {
let (host, port) = match addr {
@@ -147,9 +129,6 @@ pub fn classify(
if is_python(&name) {
return make(AttachKind::Python, "debugpy", debugpy_listen(&p.args));
}
if is_dotnet(&p.exe) {
return make(AttachKind::Dotnet, "netcoredbg", None);
}
if is_go(&p.exe) {
return make(AttachKind::Go, "delve", None);
}
@@ -164,7 +143,7 @@ const GO_SCAN_LIMIT: u64 = 96 << 20;
pub fn is_go_binary(exe: &str) -> bool {
use std::io::Read;
let skip = ["/System/", "/usr/", "/bin/", "/sbin/", "/Library/Apple/", "/Applications/"];
if skip.iter().any(|p| exe.starts_with(p)) || is_windows_system(exe) || !Path::new(exe).is_absolute() {
if skip.iter().any(|p| exe.starts_with(p)) || !Path::new(exe).is_absolute() {
return false;
}
let Ok(file) = std::fs::File::open(exe) else { return false };
@@ -186,17 +165,6 @@ pub fn is_go_binary(exe: &str) -> bool {
}
}
/// Windows' own and installed programs (`C:\Windows\…`, `C:\Program Files…`), like the
/// system locations skipped on Unix.
fn is_windows_system(exe: &str) -> bool {
let lower = exe.to_ascii_lowercase();
lower.get(1..).is_some_and(|rest| {
rest.starts_with(":\\windows\\")
|| rest.starts_with(":\\program files\\")
|| rest.starts_with(":\\program files (x86)\\")
})
}
/// The current user's processes (without this one).
#[cfg(unix)]
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
@@ -213,7 +181,7 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
let (pid, rest) = line.split_once(char::is_whitespace)?;
Some((pid.parse().ok()?, rest.trim().to_owned()))
};
// `comm` is the executable path as started on macOS; on Linux it is truncated, so read /proc.
// `comm` is the executable path on macOS; on Linux it is truncated, so read /proc.
let exes: HashMap<u32, String> =
if cfg!(target_os = "linux") { HashMap::new() } else { ps("pid=,comm=")?.lines().filter_map(split).collect() };
let me = std::process::id();
@@ -222,8 +190,9 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
.filter_map(split)
.filter(|(pid, _)| *pid != me)
.map(|(pid, args)| {
let exe = exe_path(pid)
.or_else(|| exes.get(&pid).cloned())
let exe = exes
.get(&pid)
.cloned()
.or_else(|| std::fs::read_link(format!("/proc/{pid}/exe")).ok().map(|p| p.display().to_string()))
.unwrap_or_else(|| args.split_whitespace().next().unwrap_or_default().to_owned());
RawProcess { pid, exe, args }
@@ -231,134 +200,9 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
.collect())
}
/// The processes this user may open (without this one): system and other users'
/// processes refuse `PROCESS_QUERY_LIMITED_INFORMATION` and are left out.
#[cfg(windows)]
#[cfg(not(unix))]
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
windows_processes::list()
}
#[cfg(windows)]
mod windows_processes {
use super::RawProcess;
use ide_core::{IdeError, IdeResult};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Wdk::System::Threading::{NtQueryInformationProcess, ProcessCommandLineInformation};
use windows_sys::Win32::Foundation::{INVALID_HANDLE_VALUE, UNICODE_STRING};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
};
pub fn list() -> IdeResult<Vec<RawProcess>> {
let me = std::process::id();
Ok(pids()?
.into_iter()
.filter(|&pid| pid != 0 && pid != me)
.filter_map(|pid| {
// SAFETY: plain call; a non-null result is a process handle owned here.
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: as above.
let process = unsafe { OwnedHandle::from_raw_handle(handle) };
let exe = image_path(&process)?;
let args = command_line(&process).unwrap_or_else(|| exe.clone());
Some(RawProcess { pid, exe, args })
})
.collect())
}
fn pids() -> IdeResult<Vec<u32>> {
let mut out = Vec::new();
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
return Err(IdeError::unavailable(format!(
"cannot list processes: {}",
std::io::Error::last_os_error()
)));
}
let snapshot = OwnedHandle::from_raw_handle(snapshot);
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
while more != 0 {
out.push(entry.th32ProcessID);
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
}
}
Ok(out)
}
fn image_path(process: &OwnedHandle) -> Option<String> {
let mut buf = vec![0u16; 32 * 1024];
let mut len = buf.len() as u32;
// SAFETY: a live process handle with query access; `len` is the buffer's capacity.
let ok = unsafe {
QueryFullProcessImageNameW(process.as_raw_handle(), PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len)
};
(ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
}
/// The command line (Windows 8.1+), read with the same limited query access.
fn command_line(process: &OwnedHandle) -> Option<String> {
let mut size = 0u32;
// SAFETY: a size query with no buffer; it fails and reports the size it needs.
unsafe {
NtQueryInformationProcess(
process.as_raw_handle(),
ProcessCommandLineInformation,
std::ptr::null_mut(),
0,
&mut size,
)
};
if (size as usize) < size_of::<UNICODE_STRING>() {
return None;
}
// u64 elements keep the UNICODE_STRING header aligned.
let mut buf = vec![0u64; (size as usize).div_ceil(8)];
// SAFETY: a buffer of at least `size` bytes.
let status = unsafe {
NtQueryInformationProcess(
process.as_raw_handle(),
ProcessCommandLineInformation,
buf.as_mut_ptr().cast(),
size,
&mut size,
)
};
if status < 0 {
return None;
}
// SAFETY: on success the buffer starts with a UNICODE_STRING whose text lies inside it.
let text = unsafe {
let s = &*buf.as_ptr().cast::<UNICODE_STRING>();
if s.Buffer.is_null() {
return None;
}
std::slice::from_raw_parts(s.Buffer, usize::from(s.Length) / 2)
};
Some(String::from_utf16_lossy(text))
}
}
/// The full executable path: `comm` keeps a relative path a process was started with.
#[cfg(target_os = "macos")]
fn exe_path(pid: u32) -> Option<String> {
let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
// SAFETY: the buffer holds `PROC_PIDPATHINFO_MAXSIZE` bytes, as the call requires.
let n = unsafe { libc::proc_pidpath(i32::try_from(pid).ok()?, buf.as_mut_ptr().cast(), buf.len() as u32) };
(n > 0).then(|| String::from_utf8_lossy(&buf[..n as usize]).into_owned())
}
#[cfg(all(unix, not(target_os = "macos")))]
fn exe_path(_pid: u32) -> Option<String> {
None
Err(IdeError::unavailable("Attach to Process is not supported on this platform yet"))
}
/// Node's default inspector port, which `SIGUSR1` opens.
@@ -400,7 +244,7 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
let cwd = std::env::temp_dir();
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
let mut out: Vec<AttachableProcess> =
list_processes()?.iter().filter_map(|p| classify(p, is_dotnet, is_go_binary, native)).collect();
list_processes()?.iter().filter_map(|p| classify(p, is_go_binary, native)).collect();
out.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
@@ -414,8 +258,7 @@ mod tests {
}
fn kind(exe: &str, args: &str) -> Option<(AttachKind, Option<u16>)> {
classify(&raw(exe, args), |e| e.ends_with("/Api"), |e| e.ends_with("/gosrv"), "codelldb")
.map(|p| (p.kind, p.port))
classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port))
}
#[test]
@@ -449,37 +292,11 @@ mod tests {
kind("/Library/Frameworks/Python.framework/Versions/3.12/Resources/Python.app/Contents/MacOS/Python", "x"),
Some((AttachKind::Python, None))
);
assert_eq!(kind("/home/me/Api", "./Api"), Some((AttachKind::Dotnet, None)));
assert_eq!(kind("/home/me/gosrv", "./gosrv"), Some((AttachKind::Go, None)));
assert_eq!(kind("/home/me/a.out", "./a.out"), Some((AttachKind::Native, None)));
assert!(!is_python("pythonista"));
}
#[test]
fn detects_dotnet_hosts_and_apphosts() {
let dir = std::env::temp_dir().join(format!("openidea-attach-dotnet-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let app = dir.join("Api");
std::fs::write(&app, b"").unwrap();
std::fs::write(dir.join("Api.runtimeconfig.json"), b"{}").unwrap();
let tool = dir.join("tool");
std::fs::write(&tool, b"").unwrap();
assert!(is_dotnet(app.to_str().unwrap()));
assert!(!is_dotnet(tool.to_str().unwrap()));
assert!(is_dotnet("/usr/local/share/dotnet/dotnet"));
assert!(is_dotnet("dotnet"));
assert!(!is_dotnet("Api"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn skips_windows_system_programs() {
assert!(is_windows_system(r"C:\Windows\System32\svchost.exe"));
assert!(is_windows_system(r"c:\program files (x86)\App\app.exe"));
assert!(!is_windows_system(r"D:\src\srv\srv.exe"));
assert!(!is_windows_system("/usr/bin/true"));
}
#[test]
fn detects_go_binaries_by_buildinfo() {
let dir = std::env::temp_dir().join(format!("openidea-attach-{}", std::process::id()));
-11
View File
@@ -557,17 +557,6 @@ async fn attach_to_processes() {
extra: |pid, _| serde_json::json!({"pid": pid}),
},
];
// A .NET app built into `dotnet/bin` (`<OutDir>bin</OutDir>`), attached by its apphost.
let dotnet_app = dir.join("dotnet/bin").join(if cfg!(windows) { "Server.exe" } else { "Server" });
if dotnet_app.is_file() {
cases.push(AttachCase {
kind: attach::AttachKind::Dotnet,
command: vec![s(&dotnet_app)],
source: dir.join("dotnet/Program.cs"),
line: 1,
extra: |pid, _| serde_json::json!({"processId": pid, "justMyCode": true}),
});
}
if java_server.is_some() {
cases.insert(
0,
+1 -1
View File
@@ -753,7 +753,7 @@ impl Pool {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
}
if source.driver == DbDriver::Snowflake {
return crate::snowflake::SnowflakePool::connect(source, password, setup, root).await.map(Pool::Snowflake);
return crate::snowflake::SnowflakePool::connect(source, password, setup).await.map(Pool::Snowflake);
}
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements);
+21 -58
View File
@@ -324,17 +324,6 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
/// Connection target: a data source id (its configured database), or
/// `<id>:<database>` for another database of the same server. Data source ids
/// never contain `:`.
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
fn tunneled(driver: DbDriver) -> bool {
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
}
/// The database host and port the SSH host forwards to.
fn tunnel_target(source: &DataSource) -> (&str, u16) {
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
}
pub fn split_target(target: &str) -> (&str, Option<&str>) {
match target.split_once(':') {
Some((id, db)) if !db.is_empty() => (id, Some(db)),
@@ -666,7 +655,9 @@ impl DbManager {
ssh_secret: Option<String>,
shared: bool,
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
return Ok((source.clone(), None));
};
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
@@ -677,25 +668,8 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (target_host, target_port) = tunnel_target(source);
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
/// opened with the settings typed in the data source.
async fn ssh_session(
&self,
source: &DataSource,
ssh: &DbSsh,
ssh_secret: Option<String>,
) -> IdeResult<Arc<ide_ssh::Session>> {
let config = ssh.config.trim();
Ok(if config.is_empty() {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
@@ -714,22 +688,14 @@ impl DbManager {
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
})
}
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
/// the database host and port, without logging in to the database.
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
let source = &source.resolved()?;
let ssh = source
.ssh
.as_ref()
.filter(|s| s.enabled && tunneled(source.driver))
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (host, port) = tunnel_target(source);
drop(Tunnel::open(session.clone(), host, port).await?);
Ok(format!("{} reaches {host}:{port}", session.label()))
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
@@ -1525,21 +1491,18 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
// Discarding a Snowflake connection doesn't end its session, which the data source's
// other targets share: the transaction stays open, so it stays held to be ended again.
if matches!(conn, conn::Held::Snowflake(..)) {
*held = Some(conn);
} else {
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await;
Err(e)
}
return Err(e);
}
if let Err(e) = conn.restore_default().await {
conn.discard().await;
return Err(e);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
+24 -94
View File
@@ -78,8 +78,7 @@ pub(crate) fn sso_seed(key: &str, token: &str) {
}
}
/// Live sessions by data source and connection settings, so the targets of one data source (its
/// databases, the tree, consoles) opened with the same settings share one.
/// Live sessions by their connection settings, so targets opened with the same settings share one.
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
@@ -107,15 +106,6 @@ struct Lane {
context: parking_lot::Mutex<Option<String>>,
}
/// What [`SnowflakePool::statement`] does when the session is gone and it signs in again.
#[derive(Clone, Copy)]
enum Lost<'a> {
/// Runs the statement again on the new session, after this `USE` when there is one.
Enter(Option<&'a str>),
/// Fails: the statement was part of a transaction that ended with the session.
Fail,
}
const CONSOLE: usize = 0;
const METADATA: usize = 1;
@@ -261,12 +251,7 @@ fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
}
impl SnowflakePool {
pub(crate) async fn connect(
source: &DataSource,
secret: Option<&str>,
setup: Setup,
root: Option<&std::path::Path>,
) -> IdeResult<Self> {
pub(crate) async fn connect(source: &DataSource, secret: Option<&str>, setup: Setup) -> IdeResult<Self> {
let settings = source.snowflake();
let endpoint = account::endpoint(source)?;
let user = source.user.trim();
@@ -317,15 +302,9 @@ impl SnowflakePool {
lane: CONSOLE,
};
// Everything that makes a session what it is, the secret included (hashed: the key is only compared).
// The data source and its project too: its databases share a session, but another data source
// with the same settings, or the same one in another project (a plugin's source has the same id
// in every project), gets its own: its statements and transactions are not this one's. A browser
// sign-in is still shared through the cached ID token.
let key = blake3::hash(
format!(
"{:?}\0{}\0{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
root,
source.id,
"{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
api.address(),
settings.auth,
secret.unwrap_or_default(),
@@ -657,18 +636,13 @@ impl SnowflakePool {
() = self.closed.cancelled() => return Err(closed()),
turn = self.lane().turn.lock() => turn,
};
// A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a
// transaction needs none either, and must not fail because the schema is gone meanwhile.
let context = match first_word(sql).as_str() {
"USE" | "COMMIT" | "ROLLBACK" => None,
_ => {
self.enter(schema, cancel).await?;
self.context(schema)
}
};
// A `USE` needs no schema to run in, and may be what makes the schema reachable.
if first_word(sql) != "USE" {
self.enter(schema, cancel).await?;
}
// These can move the session to another database or schema.
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE");
let result = self.statement(sql, skip, max_rows, Lost::Enter(context.as_deref()), cancel).await;
let result = self.statement(sql, skip, max_rows, cancel).await;
if moves {
*self.lane().context.lock() = None;
}
@@ -681,14 +655,16 @@ impl SnowflakePool {
&& self.lane().context.lock().as_deref() != Some(wanted.as_str())
{
*self.lane().context.lock() = None;
// Refused (the session's role may not use this database or schema): the statement
// does not run, since the session is still wherever it was, which may be another
// target's database. Tried again with the next statement.
if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await {
return Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
});
match self.statement(&wanted, 0, 0, cancel).await {
Ok(_) => {}
// The session's role may not use this database or schema (after `USE ROLE`, or a
// schema that is gone). The statement still runs, in the session's own context:
// it may be the `USE` that leads back, or name its objects in full. Asked again
// once a statement moves the session.
Err(refused) if refused.kind == ErrorKind::InvalidInput => {
tracing::warn!("Snowflake: the session did not enter its database and schema");
}
Err(error) => return Err(error),
}
*self.lane().context.lock() = Some(wanted);
}
@@ -704,11 +680,11 @@ impl SnowflakePool {
let _turn = self.lane().turn.lock().await;
let cancel = CancellationToken::new();
self.enter(None, &cancel).await?;
self.statement("BEGIN", 0, 0, Lost::Enter(self.context(None).as_deref()), &cancel).await?;
self.statement("BEGIN", 0, 0, &cancel).await?;
let changes = async {
let mut total = 0;
for (sql, expect_one) in statements {
let changed = self.statement(sql, 0, 0, Lost::Fail, &cancel).await?.affected.unwrap_or(0.0) as u64;
let changed = self.statement(sql, 0, 0, &cancel).await?.affected.unwrap_or(0.0) as u64;
if *expect_one && changed != 1 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {changed}; nothing was saved"
@@ -721,24 +697,22 @@ impl SnowflakePool {
.await;
match changes {
Ok(total) => {
self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?;
self.statement("COMMIT", 0, 0, &cancel).await?;
Ok(total)
}
Err(error) => {
let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await;
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
Err(error)
}
}
}
/// One statement on the session; the caller holds the session's turn. `lost`: what to do when
/// the session is gone and a new one is signed in to.
/// One statement on the session; the caller holds the session's turn.
async fn statement(
&self,
sql: &str,
skip: usize,
max_rows: usize,
lost: Lost<'_>,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let started = Instant::now();
@@ -769,24 +743,7 @@ impl SnowflakePool {
Ok(Outcome::Done(result)) => return Ok(*result),
Ok(Outcome::Expired { gone }) if !renewed => {
renewed = true;
if !gone {
tokens = self.renew(&tokens).await?;
continue;
}
if let Lost::Fail = lost {
// The transaction ended with the session: nothing of it was saved.
let _ = self.login().await;
return Err(IdeError::new(
ErrorKind::Io,
"Snowflake: the session was lost during the transaction; nothing was saved",
));
}
// The new session is under the login's database and schema, which may be another
// target's: back to this one's before the statement runs again.
tokens = self.login().await?;
if let Lost::Enter(Some(wanted)) = lost {
self.reenter(wanted, &tokens, cancel).await?;
}
tokens = if gone { self.login().await? } else { self.renew(&tokens).await? };
}
Ok(Outcome::Expired { .. }) => {
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"));
@@ -796,33 +753,6 @@ impl SnowflakePool {
}
}
/// [`enter`](Self::enter) on a session just signed in to (its `tokens`), within [`statement`](Self::statement).
async fn reenter(&self, wanted: &str, tokens: &Tokens, cancel: &CancellationToken) -> IdeResult<()> {
let request_id = uuid();
let outcome = tokio::select! {
biased;
() = cancel.cancelled() => return Err(IdeError::cancelled()),
() = self.closed.cancelled() => return Err(closed()),
outcome = self.run(wanted, 0, 0, tokens, &request_id, Instant::now()) => outcome,
};
match outcome {
Ok(Outcome::Done(_)) => {
*self.lane().context.lock() = Some(wanted.to_owned());
Ok(())
}
Ok(Outcome::Expired { .. }) => {
Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"))
}
Err(error) => {
let error = self.shared.api.scrub.clean(error);
Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
})
}
}
}
async fn run(
&self,
sql: &str,
+16 -106
View File
@@ -407,64 +407,13 @@ async fn a_lost_session_signs_in_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(status), ok(one()), ok(CLOSED_OK)]);
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
// The new session enters the target's schema before the statement runs again.
assert_eq!(seen[3].json()["sqlText"], "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"");
assert_eq!(seen[4].json()["sqlText"], "select 1");
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
}
#[tokio::test]
async fn a_lost_session_runs_the_statement_in_its_own_database_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string()
};
// Raw Data entered, the statement finds the session gone, a new login (under ANALYTICS),
// Raw Data entered again, the statement.
let replies = vec![ok(LOGIN_OK), ok(status()), ok(gone), ok(again), ok(status()), ok(one()), ok(CLOSED_OK)];
let server = serve(listener, replies);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut other = source(port);
other.database = "Raw Data".into();
other.options.default_schema = String::new();
let second = Pool::connect(&other, None, None).await.unwrap();
second.execute("delete from t", 1, &CancellationToken::new()).await.unwrap();
first.close().await;
second.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..6].iter().map(|r| r.json()["sqlText"].as_str().unwrap_or("").to_owned()).collect();
let raw = "USE DATABASE \"Raw Data\"";
assert_eq!(sent, [raw, "delete from t", "", raw, "delete from t"]);
}
#[tokio::test]
async fn a_transaction_ends_even_when_its_schema_is_gone() {
let (listener, port) = bind().await;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(status), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
// The console's schema was dropped since it was picked: ending the transaction doesn't switch to it.
let mut console = crate::conn::Held::acquire(&pool).await.unwrap();
console.apply_context(Some("Dropped")).await.unwrap();
console.execute(&pool, "ROLLBACK", 0, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert_eq!(seen[1].json()["sqlText"], "ROLLBACK");
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
}
#[tokio::test]
@@ -765,7 +714,7 @@ async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed()
}
#[tokio::test]
async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
@@ -773,12 +722,14 @@ async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
};
let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
let (listener, port) = bind().await;
// use role | the schema is refused, the statement is not sent | asked again, refused again |
// use role back, the schema is entered, the statement runs
// use role | the schema is refused once, the statement runs | the next one does not ask again |
// a role that is not granted | use role back, the schema is entered again
let replies = vec![
ok(LOGIN_OK),
ok(status()),
ok(refused),
ok(one()),
ok(one()),
ok(refused),
ok(status()),
ok(status()),
@@ -789,22 +740,20 @@ async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
let pool = Pool::connect(&source(port), None, None).await.unwrap();
let cancel = CancellationToken::new();
pool.execute("use role auditor", 0, &cancel).await.unwrap();
let error = pool.execute("delete from t", 1, &cancel).await.unwrap_err();
assert!(
error.message.contains("USE SCHEMA")
&& error.message.contains("was not run")
&& error.message.contains("002043"),
"{}",
error.message
);
assert!(pool.execute("select 2", 1, &cancel).await.is_err());
pool.execute("select 1", 1, &cancel).await.unwrap();
pool.execute("select 2", 1, &cancel).await.unwrap();
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
pool.execute("use role analyst", 0, &cancel).await.unwrap();
pool.execute("select 3", 1, &cancel).await.unwrap();
pool.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..7].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
server.await.unwrap()[1..9].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
assert_eq!(sent, ["use role auditor", schema, schema, "use role analyst", schema, "select 3"]);
assert_eq!(
sent,
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
);
}
#[tokio::test]
@@ -836,45 +785,6 @@ async fn databases_of_one_data_source_share_a_login() {
assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line());
}
#[tokio::test]
async fn data_sources_with_the_same_settings_have_their_own_sessions() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut copy = source(port);
copy.id = format!("{}-copy", copy.id);
let other = Pool::connect(&copy, None, None).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn one_data_source_in_two_projects_has_a_session_in_each() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
// A plugin's data source has the same id in every project.
let first = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/a"))).await.unwrap();
let other = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/b"))).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
let (listener, port) = bind().await;
-96
View File
@@ -1,96 +0,0 @@
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
//! the SSH host reaching the database host and port, with no database login.
use ide_core::error::ErrorKind;
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
use ide_ssh::{SshFiles, SshManager};
use std::sync::Arc;
use tokio::net::TcpListener;
struct Env {
_tmp: tempfile::TempDir,
db: DbManager,
ssh_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
root.join("ssh-secrets.json"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
Env { _tmp: tmp, db, ssh_port }
}
impl Env {
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
fn source(&self, db_port: u16) -> DataSource {
DataSource {
id: "pg".into(),
name: "pg".into(),
driver: DbDriver::Postgres,
host: "127.0.0.1".into(),
port: Some(db_port),
user: "app".into(),
ssh: Some(DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth: DbSshAuth::Password,
..Default::default()
}),
..Default::default()
}
}
}
/// A port something listens on (and never speaks a database protocol).
async fn listening() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_tunnel_is_tested_without_a_database_login() {
let env = env().await;
let (_db, db_port) = listening().await;
let source = env.source(db_port);
// First contact: the host key has to be trusted, as with Test Connection.
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
env.db.trust_ssh_host("pg").await.unwrap();
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
let env = env().await;
let (listener, closed) = listening().await;
drop(listener);
let source = env.source(closed);
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
env.db.trust_ssh_host("pg").await.unwrap();
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
}
#[tokio::test]
async fn a_source_without_a_tunnel_has_nothing_to_test() {
let env = env().await;
let mut source = env.source(5432);
source.ssh.as_mut().unwrap().enabled = false;
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
+1 -7
View File
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.8.0";
pub const ENGINE_VERSION: &str = "1.7.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -166,7 +166,6 @@ pub const EVENT_KINDS: &[&str] = &[
"editor-focused",
"action-performed",
"vcs-changed",
"problems-changed",
];
impl std::fmt::Display for ActivationEvent {
@@ -373,11 +372,6 @@ impl Manifest {
{
self.needs_api(&format!("`{event}`"), 6)?;
}
// Added after 1.7.0 shipped: a 1.7 host rejects the manifest, so the gate is the minor
// that carries it.
if matches!(event, ActivationEvent::Event(kind) if kind == "problems-changed") {
self.needs_api(&format!("`{event}`"), 8)?;
}
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
-78
View File
@@ -1,78 +0,0 @@
//! Host-owned diagnostics snapshot: every `Diagnostics` event lands here, whoever
//! emitted it and whatever window it was addressed to. Plugins read this instead of
//! the UI-reported Problems reports, so what a plugin sees no longer depends on
//! window lifetime, the invoking window's project, or the report loop's liveness.
use ide_core::{Diagnostic, EventSink, IdeEvent, SharedSink};
use parking_lot::RwLock;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Default)]
pub struct DiagnosticsStore(RwLock<BTreeMap<PathBuf, BTreeMap<String, Vec<Diagnostic>>>>);
impl DiagnosticsStore {
/// Full replacement for one (file, producer), like the event itself.
pub fn replace(&self, path: &PathBuf, source: &str, items: Vec<Diagnostic>) {
let mut map = self.0.write();
let sources = map.entry(path.clone()).or_default();
if items.is_empty() {
sources.remove(source);
if sources.is_empty() {
map.remove(path);
}
} else {
sources.insert(source.to_owned(), items);
}
}
/// Every file's diagnostics as one entry per producer.
pub fn problems(&self) -> Vec<(PathBuf, String, Vec<Diagnostic>)> {
let map = self.0.read();
map.iter()
.flat_map(|(path, sources)| {
sources.iter().map(move |(source, items)| (path.clone(), source.clone(), items.clone()))
})
.collect()
}
pub fn file(&self, path: &PathBuf) -> BTreeMap<String, Vec<Diagnostic>> {
self.0.read().get(path).cloned().unwrap_or_default()
}
/// Drops the files under `root` (their project closed; the producers will re-report
/// on open). Returns the number of files removed.
pub fn forget_root(&self, root: &PathBuf) -> usize {
let mut map = self.0.write();
let before = map.len();
map.retain(|path, _| !path.starts_with(root));
before - map.len()
}
}
/// Records `Diagnostics` events, then delivers them as usual.
pub struct RecordingSink {
pub inner: SharedSink,
pub store: Arc<DiagnosticsStore>,
}
impl EventSink for RecordingSink {
fn emit(&self, event: IdeEvent) {
if let IdeEvent::Diagnostics { path, source, items } = &event {
self.store.replace(&path.0.clone(), source, items.clone());
}
self.inner.emit(event);
}
}
/// A sink whose diagnostics are recorded; the store outlives every project.
pub type RecordSinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
/// Wraps a sink factory so every sink it creates records diagnostics.
pub fn recording_sink_factory(store: Arc<DiagnosticsStore>, inner: crate::SinkFactory) -> crate::SinkFactory {
Box::new(move |window| {
let inner = inner(window);
Arc::new(RecordingSink { inner, store: store.clone() }) as SharedSink
})
}
-16
View File
@@ -5,7 +5,6 @@
pub mod agent_secrets;
pub mod data_dirs;
pub mod diagnostics_store;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -59,13 +58,8 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
/// Every `Diagnostics` event the host ever emitted, per file and producer:
/// the snapshot plugins read (`problems`), independent of windows and reports.
pub diagnostics: Arc<diagnostics_store::DiagnosticsStore>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
/// Commands agents run in the Run tool window (`run_command`).
pub mcp_agent_runs: mcp::AgentRuns,
pub trust: trust::TrustStore,
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf,
@@ -139,19 +133,14 @@ impl Host {
runtime,
project_environment,
} = config;
// Every sink the host hands out records diagnostics into the shared store first.
let diagnostics: Arc<diagnostics_store::DiagnosticsStore> = Arc::default();
let sink_for: SinkFactory = diagnostics_store::recording_sink_factory(diagnostics.clone(), sink_for);
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
diagnostics,
mcp_reports: Default::default(),
mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
@@ -188,7 +177,6 @@ impl Host {
open_locks: Default::default(),
};
host.apply_proxy();
ops::agent::move_user_mcp_values(&host);
host
}
@@ -281,7 +269,6 @@ impl Host {
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.forget_window(window);
self.mcp_requests.forget_window(window);
self.mcp_agent_runs.forget_window(window);
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
@@ -296,9 +283,6 @@ impl Host {
}
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
// The producers will re-report when a project reopens; until then the snapshot
// must not answer for files whose project is gone.
self.diagnostics.forget_root(&project.root);
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
-176
View File
@@ -1,176 +0,0 @@
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
use super::editors::problem;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Severity};
use ide_lsp::DocumentCheck;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 30_000;
const MAX_FILES: usize = 20;
pub fn check_files() -> Tool {
Tool::new(
"check_files",
&format!(
"Checks files with the language servers now and returns their problems: call it after changing files \
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
Per file, `status` says what the problems are worth:
- checked: every server answered; `problems` is complete (empty means no problems).
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
- noLanguageServer: no language server is configured for this kind of file.
- untrusted: language servers don't run in an untrusted project.
- failed: the file could not be opened (`error`).
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
the file on disk. Lines and UTF-16 columns are 1-based."
),
|call| async move {
let raw = call.str_list("filePaths")?;
if raw.is_empty() {
return Err(IdeError::invalid("filePaths must name at least one file"));
}
if raw.len() > MAX_FILES {
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
}
let errors_only = call.flag("errorsOnly", true)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let mut paths = Vec::new();
for p in raw {
paths.push(call.resolve_readable(&p).await?);
}
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
let files = futures::future::join_all(checks).await;
Output::json(json!({ "files": files }))
},
)
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to return only errors (default) or warnings and hints too",
))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
}
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
let mut out = json!({ "filePath": call.display(&path) });
if !call.project().is_trusted() {
out["status"] = "untrusted".into();
return out;
}
match check(call, &path, limit).await {
Ok((check, dirty, text)) => {
let servers: Vec<Value> = check
.servers
.iter()
.map(|s| {
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
if let Some(m) = &s.message {
v["message"] = m.clone().into();
}
if s.incomplete {
v["incomplete"] = true.into();
}
if let Some(e) = &s.error {
v["error"] = e.clone().into();
}
v
})
.collect();
out["status"] = file_status(&check).into();
out["problems"] = check
.diagnostics
.into_iter()
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect::<Vec<_>>()
.into();
out["servers"] = servers.into();
if dirty {
out["unsavedInEditor"] = true.into();
}
}
Err(e) => {
out["status"] = "failed".into();
out["error"] = e.message.into();
}
}
out
}
fn file_status(check: &DocumentCheck) -> &'static str {
if check.servers.is_empty() {
"noLanguageServer"
} else if !check.servers.iter().any(|s| s.checked) {
"serverUnavailable"
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
"checked"
} else {
"incomplete"
}
}
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
let project = call.project().clone();
let docs = project.documents.clone();
let p = path.to_owned();
let at = Instant::now();
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
let (doc, changed, dirty) = call
.host()
.blocking(move || {
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
docs.on_disk_changed(std::slice::from_ref(&p));
let doc = docs.open(&p)?.id;
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
})
.await?;
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
let docs = project.documents.clone();
let _ = call
.host()
.blocking(move || {
docs.close(doc);
Ok(())
})
.await;
Ok((result?, dirty, text))
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::ServerStatus;
use ide_lsp::CheckedServer;
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
}
#[test]
fn statuses_say_whether_the_problems_are_complete() {
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
assert_eq!(check(vec![]), "noLanguageServer");
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
assert_eq!(
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
"incomplete"
);
}
}
+3 -3
View File
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
return Ok(());
}
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&call.host, call.window(), edit).await
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
}
/// The open document of `path`, or a transient one (`true`: close it after use).
@@ -317,7 +317,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
preconditions: Vec::new(),
};
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
}
FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist.
@@ -400,7 +400,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
}
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer"));
}
+1 -1
View File
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
_ => None,
});
let (h, w) = (host.clone(), window.to_owned());
let written = crate::ops::lsp::apply_agent_workspace_edit(&h, &w, edit).await;
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
+1 -1
View File
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
})
}
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
fn problem(d: Diagnostic, text: &str) -> Value {
json!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
+9 -34
View File
@@ -30,14 +30,9 @@ fn read_file() -> Tool {
"Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last
lines with a `…N lines truncated…` marker between them.
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
followed by `…N more lines; read on with offset=M…` when the file goes on.
By default, reads up to 2000 lines starting from the beginning of the file.
The maximum accepted limit is 5000 lines.",
|call| async move {
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT {
@@ -55,7 +50,7 @@ The maximum accepted limit is 5000 lines.",
)));
}
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?))
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
@@ -75,12 +70,10 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
}
}
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
/// marker between them.
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len();
if start > count {
@@ -93,15 +86,6 @@ fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> Ide
if available <= max_lines {
return Ok(range(start, count));
}
if window {
let end = start + max_lines - 1;
return Ok(format!(
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
range(start, end),
count - end,
end + 1
));
}
let head = max_lines / 2;
let tail = max_lines - head;
let head_end = start + head - 1;
@@ -261,7 +245,7 @@ mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max, false).unwrap()
render_slice(text, start, max).unwrap()
}
#[test]
@@ -270,27 +254,18 @@ mod tests {
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
}
#[test]
fn keeps_head_and_tail_of_a_long_default_read() {
fn keeps_head_and_tail_of_long_slices() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
}
#[test]
fn reads_an_explicit_range_exactly_and_says_where_to_read_on() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
let window = |start, max| render_slice(&text, start, max, true).unwrap();
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
}
#[test]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
+1 -4
View File
@@ -1,9 +1,8 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit;
mod editors;
mod requests;
pub(crate) mod run;
mod run;
mod vcs;
pub use requests::Requests;
mod files;
@@ -17,7 +16,6 @@ use crate::Host;
use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry};
pub use reports::{FileProblems, FocusedEditor, Reports};
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -32,7 +30,6 @@ fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(check::check_files());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
File diff suppressed because it is too large. Load diff
-418
View File
@@ -1,418 +0,0 @@
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
use super::output::RUN_PREVIEW_MAX_LENGTH;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
use std::sync::Arc;
const RUN_TIMEOUT_MS: usize = 10_000;
const WAIT_TIMEOUT_MS: usize = 30_000;
const STOP_TIMEOUT_MS: usize = 10_000;
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
const DEFAULT_MAX_LINES: usize = 2000;
/// Bytes of the log read for one result at most.
const READ_CAP: usize = 1 << 20;
/// Lines of output quoted when a name is still running.
const BUSY_TAIL_LINES: usize = 20;
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
wait for their confirmation before you continue.";
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
(keeps the start), MIDDLE (keeps both ends) or NONE";
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
let name = tab_name(&call.str("name")?)?;
call.host()
.mcp_agent_runs
.get(call.root(), &name)
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
}
/// `status` of `run`, with interventions not told yet.
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
let mut out = run.status();
out.extend(extra);
let interventions = run.take_interventions();
if !interventions.is_empty() {
out.insert("interventions".into(), interventions.into());
out.insert("advice".into(), INTERVENTION_ADVICE.into());
}
Output::json(Value::Object(out))
}
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
/// as `truncateMode` says.
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
let max_lines = match call.opt_i64("maxLinesCount")? {
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
Some(n) => n as usize,
None => DEFAULT_MAX_LINES,
};
let mode = match call.opt_str("truncateMode")? {
Some(name) => TruncateMode::parse(&name)
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
None => TruncateMode::Start,
};
let len = run.log_len();
let from = from.min(len);
// A long output is read from the end that stays.
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
let (raw, start, end) = run.read(start, READ_CAP)?;
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
let mut out = Map::new();
out.insert("output".into(), text.into());
if truncated > 0 {
out.insert("truncatedLines".into(), truncated.into());
}
out.insert("outputStart".into(), start.into());
out.insert("nextOffset".into(), end.into());
Ok(out)
}
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
Ok(tokio::time::Instant::now() + timeout(call, default)?)
}
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
call.opt_str("pattern")?
.filter(|p| !p.is_empty())
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
.transpose()
}
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
match call.opt_i64(name)? {
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
n => Ok(n.map(|n| n as u64)),
}
}
fn busy(run: &AgentRun) -> IdeError {
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
IdeError::invalid(format!(
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
run.name,
run.command(),
clean_output(&tail).trim_end()
))
}
pub fn run_command() -> Tool {
Tool::new(
"run_command",
&format!(
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
),
|call| async move {
let name = tab_name(&call.str("name")?)?;
let command = call.str("command")?;
if command.trim().is_empty() {
return Err(IdeError::invalid("command must not be empty"));
}
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
Some(dir) => {
let path = call.resolve_inside(&dir)?;
if !path.is_dir() {
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
}
path
}
None => call.root().to_path_buf(),
};
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
let host = call.host();
let runs = &host.mcp_agent_runs;
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
return Err(busy(&run));
}
let shown_cwd = call.display(&cwd);
let mut detail = format!(
"{command}\n\nTab: {name}\nWorking directory: {}",
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
);
for (key, value) in env.iter().flatten() {
detail.push_str(&format!("\n{key}={value}"));
}
call.confirm(&name, Some(detail)).await?;
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
let request = ide_core::event::McpUiRequest::AgentRun {
name: name.clone(),
command: command.clone(),
cwd: cwd.into(),
env: env.unwrap_or_default().into_iter().collect(),
};
if let Err(e) = call.ask(request).await {
let error = run.start_error();
runs.abandon(&run);
return Err(match error {
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
None => e,
});
}
if let Some(error) = run.start_error() {
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
}
let from = run.process_offset();
let end = run.wait(deadline, None, from, &call.cancel).await?;
let mut extra = output_view(&call, &run, from)?;
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::required(
"command",
ParamType::String,
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
))
.param(Param::optional(
"workingDirectory",
ParamType::String,
"Directory inside the project to run in, relative to the project root; default: the project root",
))
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
.param(Param::optional(
"timeout",
ParamType::Integer,
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
keeps running after it",
))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn wait_for_run() -> Tool {
Tool::new(
"wait_for_run",
&format!(
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
let pattern = pattern(&call)?;
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
let mut extra = output_view(&call, &run, from)?;
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
.param(Param::optional(
"pattern",
ParamType::String,
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
))
.param(Param::optional(
"offset",
ParamType::Integer,
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
command's output starts",
))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn read_run_output() -> Tool {
Tool::new(
"read_run_output",
&format!(
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
{OUTPUT}{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
let from = offset(&call, "offset")?.unwrap_or(0);
let extra = output_view(&call, &run, from)?;
result(&run, extra)
},
)
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn get_run_status() -> Tool {
Tool::new(
"get_run_status",
&format!(
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
result(&run, Map::new())
},
)
.param(Param::required("name", ParamType::String, NAME))
}
pub fn list_runs() -> Tool {
Tool::new(
"list_runs",
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|call| async move {
let runs: Vec<Value> =
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
Output::json(serde_json::json!({ "runs": runs }))
},
)
}
/// What a key does to a command on pipes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Key {
/// Writes this text to its input.
Text(&'static str),
/// Ctrl+C: stops it, as `stop_run` does.
Interrupt,
/// Ctrl+D: ends its input.
EndOfInput,
}
fn key(name: &str) -> IdeResult<Key> {
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
"enter" | "return" => Key::Text("\n"),
"tab" => Key::Text("\t"),
"ctrl+c" => Key::Interrupt,
"ctrl+d" => Key::EndOfInput,
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
return Err(IdeError::invalid(format!(
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
Ctrl+C to stop it or Ctrl+D to end its input"
)));
}
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
})
}
pub fn send_run_input() -> Tool {
Tool::new(
"send_run_input",
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|call| async move {
let run = find(&call)?;
let text = call.opt_str("text")?.unwrap_or_default();
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
if text.is_empty() && keys.is_empty() {
return Err(IdeError::invalid("pass `text`, `keys` or both"));
}
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
call.project().require_trust()?;
let host = call.host();
let mut data = text;
for key in keys {
match key {
Key::Text(text) => data.push_str(text),
Key::EndOfInput | Key::Interrupt => {
if !data.is_empty() {
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
}
if key == Key::EndOfInput {
crate::ops::run::close_stdin(&host, id)?;
} else {
run.agent_stopping();
crate::ops::run::stop(&host, &run.window(), id)?;
break;
}
}
}
}
if !data.is_empty() {
host.processes.write_stdin(id, &data)?;
}
result(&run, Map::new())
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("text", ParamType::String, "Text to type"))
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
}
pub fn stop_run() -> Tool {
Tool::new(
"stop_run",
&format!(
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
keeps `stopping`: call again with `force`. The tab stays open with its output."
),
|call| async move {
let run = find(&call)?;
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
let force = call.flag("force", false)?;
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
let host = call.host();
run.agent_stopping();
crate::ops::run::stop(&host, &run.window(), id)?;
if force {
// A second Stop kills the tree.
crate::ops::run::stop(&host, &run.window(), id)?;
}
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
let mut extra = Map::new();
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
}
pub fn tools() -> Vec<Tool> {
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_what_pipes_can_take() {
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
assert!(key("F13").unwrap_err().message.contains("unknown key"));
}
}
+3 -2
View File
@@ -15,6 +15,7 @@ use std::sync::Arc;
use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed.
const CONSOLE_LINES: usize = 200;
@@ -83,13 +84,13 @@ You have to use this tool after performing edits to validate if the edits are va
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
modules containing those files. Asks the user first for every call. Problems have the project-relative \
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
is an ERROR. On timeout (0 times out at once) the build is stopped.",
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|call| async move {
let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty();
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
let mut targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files {
+3 -1
View File
@@ -19,6 +19,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// No limit in JetBrains; the process keeps running after the timeout anyway.
const MAX_TIMEOUT_MS: usize = 1_800_000;
/// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long.
@@ -539,7 +541,7 @@ tool window; output files are private and deleted after a day.",
)?;
let wait = call.flag("waitForExit", true)?;
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS)?) } else { None };
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let configs = load_configs(&call).await?;
let mut config = match &target {
+5 -14
View File
@@ -1,7 +1,5 @@
//! Run configurations, builds and agent commands; every start asks the user first.
//! Run configurations and builds; every call asks the user first.
pub(crate) mod agent;
mod agent_tools;
mod build;
mod configs;
mod output;
@@ -13,22 +11,15 @@ use ide_core::IdeResult;
use std::time::Duration;
pub fn tools() -> Vec<Tool> {
let mut tools =
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
tools.extend(agent_tools::tools());
tools
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
}
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
})
}
+2 -42
View File
@@ -434,23 +434,10 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
cwd.trim_end_matches('/').to_owned()
}
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
/// runs from the workspace root), else its working directory.
fn scope_of(root: &Path, c: &RunConfig) -> String {
match c.extras.module.as_deref() {
Some(module) if c.kind == "cargo" => {
let module = module.replace('\\', "/");
let module = module.strip_prefix("./").unwrap_or(&module);
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
}
_ => cwd_of(root, c),
}
}
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> {
let cwd = scope_of(root, c);
let cwd = cwd_of(root, c);
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
};
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
@@ -935,33 +922,6 @@ mod tests {
assert_eq!(c.args, ["test", "--", "math::tests::"]);
}
#[test]
fn a_workspace_member_runs_its_own_tests() {
let root = Path::new("/p");
let member = |pkg: &str, dir: &str| {
let mut c = blank(
format!("cargo:test:{pkg}"),
"test",
"cargo",
"cargo".into(),
vec!["test".into(), "-p".into(), pkg.into()],
);
c.is_test = true;
c.extras.module = Some(dir.into());
c
};
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
workspace.is_test = true;
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
let rel = "crates/ide-mcp/src/stdio.rs";
let ts = detect(src, rel);
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
}
#[test]
fn cargo_test_args_get_one_separator() {
let root = Path::new("/p");
+2 -131
View File
@@ -38,14 +38,6 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
each side. At most 50. Default: the matched line only.";
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
/// on each side of it; a longer context line to its start.
const MAX_LINE_TEXT: usize = 300;
const SNIPPET_AROUND: usize = 100;
const MAX_CONTEXT_LINES: u32 = 50;
pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()]
@@ -57,8 +49,6 @@ fn search_text() -> Tool {
"Searches for a text substring within project files.
Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
@@ -67,7 +57,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -77,8 +66,6 @@ fn search_regex() -> Tool {
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root.
@@ -88,7 +75,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -98,7 +84,6 @@ fn search_symbol() -> Tool {
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive).
Project results have their line (`lineText`), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
By default this searches project symbols only.
@@ -114,7 +99,6 @@ If you don't find a suitable result, try again with include_external=true to sea
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -161,13 +145,6 @@ struct SearchItem {
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
/// The text of `start_line` (cut when long).
#[serde(skip_serializing_if = "Option::is_none")]
line_text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_before: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_after: Vec<String>,
}
impl SearchItem {
@@ -205,9 +182,7 @@ fn result_schema() -> Value {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string, "lineText": string,
"contextBefore": { "type": "array", "items": string },
"contextAfter": { "type": "array", "items": string },
"name": string, "container": string,
},
"required": ["filePath"],
} },
@@ -232,87 +207,6 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
Ok(q)
}
// --- matched lines -------------------------------------------------------------
/// The `context` argument as (lines before, lines after).
fn context(call: &Call) -> IdeResult<(u32, u32)> {
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
let (sign, n) = match call.args.get("context") {
None | Some(Value::Null) => return Ok((0, 0)),
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
Some(Value::String(s)) => {
let s = s.trim();
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
None => (None, s.parse().map_err(|_| invalid())?),
}
}
Some(_) => return Err(invalid()),
};
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
Ok(match sign {
Some('+') => (0, n),
Some(_) => (n, 0),
None => (n, n),
})
}
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
fn snippet(line: &str, start: usize, end: usize) -> String {
let units: usize = line.chars().map(char::len_utf16).sum();
if units <= MAX_LINE_TEXT {
return line.to_owned();
}
let from = start.saturating_sub(SNIPPET_AROUND);
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
let (mut out, mut at) = (String::new(), 0);
for c in line.chars() {
if at >= from && at < to {
out.push(c);
}
at += c.len_utf16();
}
let before = if from > 0 { "\u{2026}" } else { "" };
let after = if to < units { "\u{2026}" } else { "" };
format!("{before}{out}{after}")
}
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
fn context_line(line: &str) -> String {
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
}
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
/// as they are); each file is read once, with unsaved editor text.
async fn fill_lines(
call: &Call,
items: &mut [SearchItem],
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
(before, after): (u32, u32),
) {
let host = call.host();
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
for item in items {
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
if current.as_ref().is_none_or(|(p, _)| p != &path) {
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
current = Some((path, lines));
}
let Some((_, Some(lines))) = &current else { continue };
let index = line as usize - 1;
let Some(text) = lines.get(index) else { continue };
let start = item.start_column.unwrap_or(1) as usize - 1;
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
item.line_text = Some(snippet(text, start, end));
let first = index.saturating_sub(before as usize);
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
let last = (index + after as usize).min(lines.len() - 1);
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
}
}
// --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
@@ -522,7 +416,6 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop.
@@ -601,7 +494,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let mut items: Vec<SearchItem> = hits
let items = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
@@ -612,9 +505,6 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.collect();
let absolute: std::collections::HashMap<&str, &Path> =
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
@@ -836,7 +726,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
@@ -886,13 +775,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
let root = call.root().to_path_buf();
let absolute = |item: &SearchItem| {
let path = Path::new(&item.file_path);
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
};
fill_lines(&call, &mut items, absolute, around).await;
let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
@@ -987,17 +869,6 @@ mod tests {
assert_eq!(byte_at("ab", 5), None);
}
#[test]
fn long_lines_are_cut_around_the_match() {
assert_eq!(snippet("short line", 0, 5), "short line");
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
let cut = snippet(&line, 400, 406);
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
let start = snippet(&line, 0, 0);
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
}
#[test]
fn path_scopes() {
let (_dir, root) = root();
+7 -103
View File
@@ -2,7 +2,6 @@
//! window they run on the host, next to the project they work on.
use super::{Channel, Emit};
use crate::Host;
use crate::agent_secrets::{AgentSecrets, Kept};
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
@@ -343,77 +342,8 @@ pub async fn accept_changes(
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
/// Where the values of the user's MCP servers are kept in [`AgentSecrets`]. A project's own
/// servers keep their values in the project's file, where the project shares them.
const USER_MCP_VALUES: &str = "mcp/user/";
/// Every environment variable and header value of `map`, by its key in [`AgentSecrets`].
fn mcp_values(map: &mut McpServerMap) -> Vec<(String, &mut String)> {
let part = |s: &str| s.replace('%', "%25").replace('/', "%2F");
let mut values = Vec::new();
for (server, cfg) in map.iter_mut() {
for (kind, entries) in [("env", &mut cfg.env), ("header", &mut cfg.headers)] {
for (name, value) in entries.iter_mut() {
values.push((format!("{USER_MCP_VALUES}{}/{kind}/{}", part(server), part(name)), value));
}
}
}
values
}
fn has_mcp_values(map: &McpServerMap) -> bool {
map.values().any(|s| s.env.values().chain(s.headers.values()).any(|v| !v.is_empty()))
}
/// Fills in the saved values. A value written in the settings file wins: an empty one stands for the saved one.
fn fill_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) {
for (key, value) in mcp_values(map) {
if value.is_empty()
&& let Some(saved) = secrets.get(&key)
{
*value = saved;
}
}
}
/// Saves the values of `map` and empties them in it; values of servers and names no
/// longer in it are forgotten.
fn take_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) -> IdeResult<Kept> {
let mut kept = Kept::Store;
let mut current = HashSet::new();
for (key, value) in mcp_values(map) {
if value.is_empty() {
secrets.delete(&key);
} else if secrets.get(&key).as_deref() != Some(value.as_str()) && secrets.set(&key, value)? == Kept::LocalFile {
kept = Kept::LocalFile;
}
value.clear();
current.insert(key);
}
for key in secrets.keys(USER_MCP_VALUES) {
if !current.contains(&key) {
secrets.delete(&key);
}
}
Ok(kept)
}
/// Values written into the user settings (by hand, by an older version, by an import) move to [`AgentSecrets`].
pub fn move_user_mcp_values(host: &Host) {
let mut user = host.global_settings.effective().agents.mcp_servers;
if !has_mcp_values(&user) {
return;
}
fill_mcp_values(&host.agent_secrets, &mut user);
if let Err(e) = persist_mcp_map(host, None, McpScope::User, &user) {
tracing::warn!("MCP servers: could not move values out of the user settings: {}", e.message);
}
}
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
move_user_mcp_values(host);
let mut user = host.global_settings.effective().agents.mcp_servers;
fill_mcp_values(&host.agent_secrets, &mut user);
let user = host.global_settings.effective().agents.mcp_servers;
let project = host.project(window).ok().filter(|p| !p.light);
let workspace = project
.as_ref()
@@ -428,15 +358,10 @@ fn persist_mcp_map(
scope: McpScope,
map: &McpServerMap,
) -> IdeResult<()> {
let mut map = map.clone();
let kept = match scope {
McpScope::User => take_mcp_values(&host.agent_secrets, &mut map)?,
McpScope::Workspace => Kept::Store,
};
let value = if map.is_empty() {
serde_json::Value::Null
} else {
serde_json::to_value(&map).map_err(|e| IdeError::invalid(e.to_string()))?
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
};
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
match scope {
@@ -445,9 +370,6 @@ fn persist_mcp_map(
for p in host.projects() {
p.settings.reload();
p.sink.emit(event.clone());
if kept == Kept::LocalFile {
host.agent_secrets.notify_kept_locally(&p.sink);
}
}
}
McpScope::Workspace => {
@@ -496,17 +418,15 @@ fn listed(
ide_agent::mcp::servers_from_settings(user, &workspace)
}
/// The MCP servers agent sessions of the project at `root` get: the user's, values filled in, and
/// the project's the user turned on.
/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the
/// user turned on.
pub(crate) fn session_mcp_servers(
settings: &ide_workspace::SettingsStore,
approvals: &crate::trust::McpServerApprovals,
secrets: &AgentSecrets,
root: &std::path::Path,
) -> Vec<McpServer> {
use ide_workspace::settings::SettingsScope;
let mut user = settings.layer(SettingsScope::Global).agents.mcp_servers;
fill_mcp_values(secrets, &mut user);
let user = settings.layer(SettingsScope::Global).agents.mcp_servers;
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
ide_agent::mcp::servers_from_settings(&user, &workspace)
}
@@ -673,7 +593,6 @@ mod tests {
#[test]
fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let (config, root) = (tmp.path().join("config"), tmp.path().join("project"));
std::fs::create_dir_all(&config).unwrap();
@@ -686,7 +605,6 @@ mod tests {
.unwrap();
let settings = ide_workspace::SettingsStore::load(&config, Some(&root));
let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
let secrets = AgentSecrets::new(&tmp.path().join("data"));
let on = |servers: Vec<McpServer>| -> Vec<(McpScope, String, String)> {
servers
.into_iter()
@@ -695,27 +613,13 @@ mod tests {
.collect()
};
assert_eq!(
on(session_mcp_servers(&settings, &approvals, &secrets, &root)),
on(session_mcp_servers(&settings, &approvals, &root)),
[(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())]
);
// A user server's saved values reach the session; the file keeps only the names.
std::fs::write(
config.join("settings.toml"),
"[agents.mcpServers.keyed]\ncommand = \"k\"\nenv = { TOKEN = \"\" }\n",
)
.unwrap();
secrets.set("mcp/user/keyed/env/TOKEN", "saved-user").unwrap();
settings.reload();
let keyed = session_mcp_servers(&settings, &approvals, &secrets, &root);
let keyed = keyed.iter().find(|s| s.name == "keyed").unwrap();
assert_eq!(keyed.env[0].value, "saved-user");
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
settings.reload();
let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone();
approvals.approve(&root, "extra", &extra).unwrap();
assert!(on(session_mcp_servers(&settings, &approvals, &secrets, &root)).contains(&(
assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&(
McpScope::Workspace,
"extra".to_owned(),
"extra".to_owned()
+4 -90
View File
@@ -15,7 +15,6 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::path::Path;
use std::sync::Arc;
fn join_err(e: tokio::task::JoinError) -> IdeError {
IdeError::internal(e.to_string())
@@ -314,11 +313,10 @@ pub async fn build_sync(
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
// New source folders and dependency versions: the Project view and External Libraries reload.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
@@ -331,61 +329,21 @@ pub async fn build_sync(
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let lsp = project.lsp.clone();
let documents = project.documents.clone();
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
let first = updates.first().cloned();
for file in updates {
let uri = ide_lsp::path_to_uri_string(&file);
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
}
let changed = {
let lsp = lsp.clone();
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
};
if let Some(file) = first
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
{
tracing::debug!("openidea.java.refreshArchives: {e}");
}
refresh_library_classes(&lsp, &documents).await;
});
Ok(model)
}
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
/// again keeps its path) show the new contents, like a reopened one.
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
let changed = {
let (lsp, documents) = (lsp.clone(), documents.clone());
tokio::task::spawn_blocking(move || {
documents
.open_documents()
.into_iter()
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
.filter(|(_, uri)| lsp.library_class_changed(uri))
.collect::<Vec<_>>()
})
.await
.unwrap_or_default()
};
for (doc, uri) in changed {
match lsp.virtual_document(&uri).await {
Ok(vd) => {
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
tracing::debug!("refreshing {uri}: {e}");
}
}
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
}
}
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
@@ -497,8 +455,7 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project), else the artifact's own
/// POM (a project module's, or the local repository's). `None` when nothing
/// the local repository when a parent is not in the project). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
@@ -509,19 +466,7 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
let modules = || {
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
model
.projects
.into_iter()
.map(|p| ide_build::maven::ModulePom {
group_id: p.group_id,
artifact_id: p.artifact_id,
pom: project.root.join(&p.pom),
})
.collect()
};
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
})
.await
.map_err(join_err)?
@@ -1026,38 +971,6 @@ mod build_tool_settings_tests {
}
}
/// Changing Project JDK updates the next Maven build without reopening the project.
#[test]
fn changing_project_jdk_updates_the_next_maven_build() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
for (name, version) in [("jdk8", "1.8"), ("jdk21", "21")] {
let home = root.join(name);
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
}
let mut settings = Settings::default();
settings.languages.java.maven.runner_jre.clear();
for name in ["jdk8", "jdk21"] {
settings.languages.java.jdk = name.into();
let expected = root.join(name);
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert!(!plan.steps.is_empty());
for step in plan.steps {
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
}
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
@@ -1092,6 +1005,7 @@ mod maven_completion_tests {
use ide_build::maven::completion::PomCompletionKind;
use ide_core::CancellationToken;
use ide_workspace::dependencies::DependencyVersionSource;
use std::sync::Arc;
/// Cache answers the test plants before completing, so the op's Central
/// lookups hit the session cache and the network is never reached.
-15
View File
@@ -42,10 +42,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_test_tunnel", |c| async move {
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
test_tunnel(&c.host, &c.window, source, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
@@ -216,17 +212,6 @@ pub async fn test(
db.test(&source, password, ssh_secret).await
}
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
pub async fn test_tunnel(
host: &Host,
window: &str,
source: DataSource,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test_tunnel(&source, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
+3 -9
View File
@@ -188,14 +188,8 @@ pub async fn remote_download_into(
r
}
/// `password`: an FTP password typed in the settings but not applied yet.
pub async fn deploy_test_connection(
state: &Host,
connection: FsConnection,
path: String,
password: Option<String>,
) -> IdeResult<()> {
state.remote.test_connection_with(&connection, &path, password).await
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
@@ -237,6 +231,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
d.add("deploy_test_connection", |c| async move {
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?, c.arg("password")?).await
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
});
}
+27 -97
View File
@@ -96,19 +96,18 @@ async fn resolve_launch(
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let built = if delegated {
let rebuild_on_jdt_errors = rebuild_on_jdt_errors(policy, prefer_maven, info.error_count);
let policy = delegated_policy(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
&module,
cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
local_repo.clone(),
cache::BuildOptions { test, policy },
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
apply_external_build_result(&mut info, outputs.failure, outputs.authoritative);
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
copies
} else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -124,22 +123,10 @@ async fn resolve_launch(
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
if delegated {
// m2e runs inside jdtls on the server JDK and can resolve a different
// Maven profile/classifier than the module build. Ask the same Maven
// process that built the module for its runtime/test dependencies.
let maven_cp = compile::module_classpath(
&project.root,
&model,
&module,
test,
local_repo,
&project.build_cache_dir(),
&cancel,
);
merge_maven_launch_classpath(&mut class_paths, &mut info, maven_cp)?;
} else {
append_unique_class_paths(&mut class_paths, std::mem::take(&mut info.class_paths));
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
}
info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
@@ -204,18 +191,21 @@ async fn resolve_launch(
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
/// compiler, so a launch JDT reports errors for recompiles everything.
fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool {
prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make)
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
BuildPolicy::Rebuild
} else {
policy
}
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, authoritative: bool) {
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
JavaBuildStatus::Failed
} else if authoritative {
// A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics
// may describe an older output or its own classpath, so they must not mark this launch
// as failed after Maven has produced usable classes.
} else if clean {
// A clean external build recompiled every source; JDT's diagnostics still describe
// the previous output (and reject sources javac accepts, e.g. a package mismatch).
info.errors.clear();
info.error_count = 0;
JavaBuildStatus::Succeeded
@@ -296,44 +286,6 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
/// Merge the Maven launch classpath without turning a recoverable probe failure into a
/// failed launch. Cancellation still propagates; ordinary failures keep jdtls' classpath
/// and leave the reason in the run console.
fn merge_maven_launch_classpath(
class_paths: &mut Vec<String>,
info: &mut JavaLaunchInfo,
classpath: IdeResult<String>,
) -> IdeResult<()> {
match classpath {
Ok(classpath) => {
append_unique_class_paths(
class_paths,
std::env::split_paths(&classpath).map(|path| path.to_string_lossy().into_owned()),
);
Ok(())
}
Err(error) if error.kind == ErrorKind::Cancelled => Err(error),
Err(error) => {
info.hints.push(format!(
"Maven launch classpath resolution failed; using the Java language server classpath instead:\n{}",
error.message
));
append_unique_class_paths(class_paths, std::mem::take(&mut info.class_paths));
Ok(())
}
}
}
/// Append entries without changing their precedence; `same_entry` also catches
/// Windows case-insensitive duplicates that would otherwise lengthen the command.
fn append_unique_class_paths(class_paths: &mut Vec<String>, entries: impl IntoIterator<Item = String>) {
for entry in entries {
if !class_paths.iter().any(|existing| same_entry(existing, &entry, cfg!(windows))) {
class_paths.push(entry);
}
}
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
@@ -458,36 +410,14 @@ mod tests {
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1));
assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1));
assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1));
}
#[test]
fn maven_classpath_probe_failure_falls_back_to_jdtls_classpaths() {
let maven = std::env::join_paths(["maven.jar", "snapshot.jar"]).unwrap().to_string_lossy().into_owned();
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info =
JavaLaunchInfo { class_paths: vec!["snapshot.jar".into(), "jdtls.jar".into()], ..Default::default() };
merge_maven_launch_classpath(&mut class_paths, &mut info, Ok(maven)).unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar"]);
merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::internal("offline dependency")))
.unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar", "jdtls.jar"]);
assert!(info.hints[0].contains("offline dependency"), "{:?}", info.hints);
}
#[test]
fn cancelled_maven_classpath_probe_propagates_without_fallback() {
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info = JavaLaunchInfo { class_paths: vec!["jdtls.jar".into()], ..Default::default() };
let error = merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::cancelled())).unwrap_err();
assert_eq!(error.kind, ErrorKind::Cancelled);
assert_eq!(class_paths, ["snapshot.jar"]);
assert!(info.hints.is_empty());
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert_eq!(
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
);
}
#[test]
@@ -531,7 +461,7 @@ mod tests {
}
#[test]
fn authoritative_external_result_clears_stale_jdt_errors() {
fn clean_external_build_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo {
error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError {
+4 -71
View File
@@ -14,10 +14,6 @@ pub(super) struct Outputs {
pub originals: Vec<PathBuf>,
#[serde(skip)]
pub failure: Option<String>,
/// Whether this result replaces jdtls diagnostics with a clean Maven build or a
/// verified snapshot. It is runtime-only because reuse is determined per launch.
#[serde(skip)]
pub authoritative: bool,
}
impl Outputs {
@@ -46,9 +42,6 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
pub(super) struct BuildOptions {
pub test: bool,
pub policy: BuildPolicy,
/// A JDT error makes a cache miss rebuild, but it must not prevent an unchanged
/// snapshot from being reused on the next launch.
pub rebuild_on_jdt_errors: bool,
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
@@ -82,7 +75,7 @@ impl Session {
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
let BuildOptions { test, policy } = options;
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -122,14 +115,8 @@ impl Session {
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
let mut outputs = previous.unwrap();
outputs.authoritative = true;
return Ok(outputs);
return Ok(previous.unwrap());
}
// JDT can report errors from its own classpath while the last Maven snapshot is
// still valid. Reuse that snapshot first; only a cache miss needs the expensive
// clean rebuild that replaces JDT's error-stub classes.
let policy = if rebuild_on_jdt_errors { BuildPolicy::Rebuild } else { policy };
// Retain a non-reusable record even on failure, so removing the final mixed
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
@@ -169,13 +156,7 @@ impl Session {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs {
fingerprint: None,
snapshot,
originals,
failure: built.failure,
authoritative: policy == BuildPolicy::Rebuild,
};
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
if outputs.failure.is_none() && before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
@@ -353,48 +334,6 @@ mod tests {
);
}
#[test]
fn jdt_errors_reuse_an_unchanged_snapshot_and_rebuild_only_on_a_cache_miss() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cancel = CancellationToken::new();
let mut session = Session::new(root.join("cache"));
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert!(!first.authoritative);
let reused = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(reused.snapshot, first.snapshot);
assert!(reused.authoritative);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let rebuilt = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot);
assert!(rebuilt.authoritative);
let calls = fs::read_to_string(root.join("calls")).unwrap();
assert_eq!(calls.lines().count(), 2);
assert!(calls.lines().last().unwrap().contains("clean"));
}
#[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -544,13 +483,7 @@ mod tests {
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs {
fingerprint: None,
snapshot: retained.clone(),
originals: Vec::new(),
failure: None,
authoritative: false,
};
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
+14 -81
View File
@@ -74,15 +74,9 @@ pub async fn lsp_completion(
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
.await
.unwrap_or_default();
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return if covered {
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
} else {
result
};
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
@@ -315,13 +309,9 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
// editor to tell.
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
&& !on_declaration_name(host, window, doc, pos).await
{
let project = host.project(window)?;
// A notebook cell is a language-server document only: no plugin navigates in it.
@@ -362,30 +352,6 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
definition_targets(result, found, in_string)
}
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
Ok(is_declaration_name(&at))
})
.await
.unwrap_or(false)
}
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
at.node.field.as_deref() == Some("name")
&& at.ancestors.first().is_some_and(|parent| {
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
})
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
@@ -1517,38 +1483,23 @@ pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
})
.await
}
/// [`apply_workspace_edit`] for the MCP server's tools: undoable the same way, but the UI does
/// not announce it as a refactoring.
pub async fn apply_agent_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
project.documents.apply_agent_workspace_edit(&edit)
})
.await
}
/// Multi-file edits leave a Local History point to go back to.
fn record_before_multi_file_edit(project: &crate::Project, edit: &WorkspaceEdit) {
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
}
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?;
blocking(move || {
@@ -2170,24 +2121,6 @@ mod tests {
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn declaration_names_are_told_apart_from_uses() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = ide_core::LanguageId::new("java");
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
let snap = ide_syntax::Snapshot::text(&java, text);
let cancel = ide_core::CancellationToken::new();
let at = |line, col| {
let pos = Pos::new(line, col);
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
};
assert!(at(1, 10), "the mapper method's name");
assert!(at(0, 12), "the interface's name");
assert!(!at(3, 45), "a call of the method");
assert!(!at(1, 4), "its return type");
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
+1 -20
View File
@@ -234,19 +234,7 @@ pub async fn start_request(
) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let operation = config.extras.params.get("plugin.operation").cloned();
let mut config = config;
let agent = match host.project(window) {
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
Err(_) => None,
};
let on_output = match &agent {
Some(agent) => agent.wrap_output(on_output),
None => on_output,
};
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
if let Some(agent) = &agent {
agent.started(&result);
}
let result = start_inner(host, window, config, on_output, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
@@ -261,7 +249,6 @@ async fn start_inner(
window: &str,
mut config: RunConfig,
on_output: Emit<OutputChunk>,
agent: Option<&crate::mcp::run::agent::Launch>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> {
if cancel.is_cancelled() {
@@ -295,9 +282,6 @@ async fn start_inner(
if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?;
}
if let Some(agent) = agent {
sink = agent.wrap_sink(sink);
}
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
@@ -466,7 +450,6 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
if let Some(plugins) = host.plugin_host() {
plugins.run_operation_stopped(id);
}
host.mcp_agent_runs.stopping(id);
let settings = match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
@@ -478,9 +461,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
host.processes.stop_with_grace(id, grace)
}
/// Input the user typed into a run (the agent's goes to the process directly).
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
host.mcp_agent_runs.user_input(id, data);
if host.remote.runs.contains(id) {
return host.remote.runs.write_stdin(id, data);
}
+6 -13
View File
@@ -61,16 +61,13 @@ pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeRe
host.require_trust(window)?;
let current = host.project(window).ok().map(|p| p.root.clone());
let config = host.config_dir.clone();
// Probing runs the project's interpreter (and its settings may name any program): only
// projects trusted in their own right, not ones last opened in Restricted Mode.
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.filter(|root| host.trust.window_trusted(root).unwrap_or(false))
.collect();
tokio::task::spawn_blocking(move || {
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.collect();
// Each project resolves its configured interpreter, else its own virtualenv. The
// probes run `python`, so keep them off the async runtime and bound how many run at
// once: the recent list can hold ~30 projects, and each probe starts a process.
@@ -524,10 +521,6 @@ mod tests {
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
// A project last opened in Restricted Mode: its interpreter is never run.
host.trust.decide(&other, false, false).unwrap();
assert!(sdk_python_project_interpreters(&host, "main").await.unwrap().is_empty());
host.trust.decide(&other, true, false).unwrap();
let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(&*list[0].project, other.as_path());
+4 -28
View File
@@ -47,17 +47,14 @@ pub async fn spell_check(
let syntax = host.syntax();
let cancel = host.begin_request(request);
let result = blocking(move || {
let path = project.documents.snapshot(doc)?.path.0.clone();
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok((path, Vec::new()));
return Ok(Vec::new());
}
let regions = if PROSE.contains(&snap.language.as_str()) {
ide_spell::prose_regions(&snap.text)
} else {
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else {
return Ok((path, Vec::new()));
};
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
found
.into_iter()
.map(|r| Region {
@@ -75,32 +72,11 @@ pub async fn spell_check(
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let typos =
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
Ok((path, typos))
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
})
.await;
host.end_request(request);
// Record the typos in the host's diagnostics snapshot so line-end annotations
// see them without waiting for a UI report; the editor still draws from its
// own call above, unchanged.
if let Ok((path, typos)) = &result {
let items: Vec<ide_core::Diagnostic> = typos
.iter()
.map(|t| ide_core::Diagnostic {
range: t.range,
severity: ide_core::Severity::Info,
message: format!("Typo: In word '{}'", t.word),
source: Some("Spelling".into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.collect();
host.diagnostics.replace(path, "Spelling", items);
}
result.map(|(_, typos)| typos)
result
}
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
+1 -12
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
@@ -89,16 +89,6 @@ pub async fn tools_install(
Ok(info)
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
let tools = tools(host, window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Removes a tool the IDE installed.
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
let tools = tools(host, window)?;
@@ -195,7 +185,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
});
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
d.add("jdk_download_releases", |c| async move {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
-21
View File
@@ -22,27 +22,6 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
.await
}
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
/// to no repository are ignored, and an empty list is the full status.
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status_paths(&paths)
})
.await
}
mod commands;
pub use commands::*;
+1 -1
View File
@@ -1507,7 +1507,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
+28 -149
View File
@@ -1642,9 +1642,6 @@ impl HostApi for PluginApi {
) -> Result<(), HostError> {
self.set_decorations(path, set, Vec::new(), item, true)
}
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
self.set_file_icon_decorations(set, items)
}
fn vcs_log(
&self,
root: &Path,
@@ -3179,17 +3176,26 @@ impl PluginWorkspace for HostWorkspace {
Ok(out)
}
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<ide_plugin::FileProblems>, HostError> {
// The host records every `Diagnostics` event it emits (language servers,
// builds, spelling, plugin providers), so this sees a file's problems
// whatever window opened it — not just the ones its UI reported.
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
host.project(client).map_err(failed)?;
let root = host.project(client).map_err(failed)?.root.clone();
let windows = host.windows_for_root(&root);
let mut out = Vec::new();
for (file, source, diagnostics) in host.diagnostics.problems() {
if path.is_some_and(|p| p != file) {
for file in host.mcp_reports.problems(&windows) {
if path.is_some_and(|p| p != file.path.0) {
continue;
}
out.push(ide_plugin::FileProblems { path: to_ipc(&file), source, diagnostics });
if ide_plugin::resolve_new_in_root(&root, &to_ipc(&file.path.0)).is_err() {
continue;
}
let mut sources: BTreeMap<String, Vec<ide_core::Diagnostic>> = BTreeMap::new();
for d in file.items {
sources.entry(d.server.clone().or_else(|| d.source.clone()).unwrap_or_default()).or_default().push(d);
}
out.extend(sources.into_iter().map(|(source, diagnostics)| ide_plugin::FileProblems {
path: to_ipc(&file.path.0),
source,
diagnostics,
}));
}
Ok(out)
}
@@ -3315,8 +3321,6 @@ impl PluginWorkspace for HostWorkspace {
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
// Any open project may publish problems for any file: annotations follow the
// file, not the window (an editor can show a file outside every project root).
let projects = HostWorkspace::projects(self);
let project = projects
.iter()
@@ -3328,8 +3332,7 @@ impl PluginWorkspace for HostWorkspace {
.iter()
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
})
.or_else(|| projects.first())
.ok_or_else(|| HostError::NotFound("no project is open".into()))?;
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin:{source}"),
@@ -4122,30 +4125,25 @@ impl PluginApi {
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
return Err(HostError::Invalid("decoration limit exceeded".into()));
}
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
}
let replacement = if let Some(path) = path {
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self.decoration_root(path);
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project open".into()))?;
let (root, real) = workspace
.projects()
.into_iter()
.filter_map(|root| ide_plugin::resolve_new_in_root(&root, &to_ipc(path)).ok().map(|real| (root, real)))
.max_by_key(|(root, _)| root.components().count())
.ok_or_else(|| HostError::Invalid("path outside open projects".into()))?;
Some(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path.to_path_buf()),
path: real.into(),
set_id: set.into(),
tree,
items,
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
file: file.filter(|f| decoration_style(&f.style)),
})
} else {
None
@@ -4173,60 +4171,6 @@ impl PluginApi {
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
fn decoration_root(&self, path: &Path) -> PathBuf {
self.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
}
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
if set.is_empty() || set.len() > 128 {
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
}
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
}
let mut by_path = BTreeMap::new();
for (path, icon) in items {
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
by_path.insert(path, icon);
}
let mut all = self.shared.decorations.lock();
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
sets.retain(|s| !(s.tree && s.set_id == set));
for (path, icon) in by_path {
let root = self.decoration_root(&path);
sets.push(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path),
set_id: set.into(),
tree: true,
items: Vec::new(),
file: Some(ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: None,
note: None,
icon: Some(icon),
}),
});
}
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
}
all.insert(self.id().into(), sets.clone());
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
}
fn decoration_style(style: &str) -> bool {
matches!(
@@ -4239,8 +4183,6 @@ fn decoration_style(style: &str) -> bool {
| "warning"
| "error"
| "info"
| "typo"
| "weak"
| "underline"
| "strikethrough"
| "muted"
@@ -4361,11 +4303,8 @@ mod workbench_tests {
};
api.decorations(Some(&path), "set", vec![item("highlight"), item("unknown")]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"][0].items.len(), 1);
// Decorations follow the file: a path outside the open project is accepted too.
api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"].len(), 2);
assert!(api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).is_err());
api.decorations(None, "set", vec![]).unwrap();
assert!(api.shared.decoration_sets()["test"].is_empty());
for i in 0..50 {
api.decorations(Some(&path), &format!("s{i}"), vec![]).unwrap();
}
@@ -4376,73 +4315,13 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"tree",
Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None,
icon: None,
})
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
)
.is_err()
);
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
icon: None,
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "notes")
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
.collect::<Vec<_>>()
};
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.file_icon_decorations(
"icons",
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
)
.unwrap();
let icons = api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "icons")
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
.collect::<Vec<_>>();
assert_eq!(icons.len(), 2);
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
#[test]
fn decoration_styles_keep_the_severity_palette_the_editor_renders() {
// Inspection Lens colours typos and weak warnings with their own styles; the
// host must keep them, not silently drop them the way an unknown style is dropped.
for style in [
"highlight",
"search",
"added",
"modified",
"removed",
"warning",
"error",
"info",
"typo",
"weak",
"underline",
"strikethrough",
"muted",
] {
assert!(decoration_style(style), "{style} should be a known style");
}
assert!(!decoration_style("unknown"));
}
fn git(root: &Path, args: &[&str]) {
let out = StdCommand::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+9 -19
View File
@@ -129,7 +129,6 @@ impl PluginHost {
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
WorkbenchEvent::EditorFocused(Some(path)) => Path::new(path),
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
_ => return Some(event.clone()),
};
@@ -137,25 +136,16 @@ impl PluginHost {
return None;
}
let workspace = workspace.as_ref()?;
let real = ide_plugin::resolve_real(path);
// The file's own project, not the invoking window's: annotations follow
// the file, so a plugin hears about a file another window has open.
let root = match client {
Some(client) => workspace
.project_of(client)
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.or_else(|| {
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}),
None => workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count()),
Some(client) => workspace.project_of(client),
None => {
let real = ide_plugin::resolve_real(path);
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}
}?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
+2 -25
View File
@@ -396,37 +396,15 @@ impl PluginHost {
}
}
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
self.provider_plugins(kind, &doc.language)
.into_iter()
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
self.provide_completion_for_editor(doc, at, trigger).1
}
pub(crate) fn provide_completion_for_editor(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> (bool, Vec<(String, CompletionItem)>) {
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
let items = self
.feature_documents(doc, Some(at))
self.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
if self.has_document_providers(ProviderKind::Completion, &doc) {
covered = true;
}
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
@@ -441,8 +419,7 @@ impl PluginHost {
Some((p, i))
})
})
.collect();
(covered, items)
.collect()
}
pub fn provide_code_actions(
+5 -46
View File
@@ -352,15 +352,6 @@ where
deserializer.deserialize_map(Properties)
}
/// A sample shown in a setting's own style (the Colour Scheme colours), e.g. the
/// message an Inspection Lens severity produces.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct SettingExample {
pub text: String,
/// `error`, `warning`, `weak`, `typo`, `info` or `muted`: which sample style to draw.
pub style: String,
}
/// One setting of a plugin.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -380,8 +371,6 @@ pub struct SettingSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<SettingExample>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<Box<SettingSchema>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<SettingSchema>,
@@ -466,20 +455,6 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
}
};
let example = match o.get("example") {
None => None,
Some(v) => {
let e: SettingExample = serde_json::from_value(v.clone())
.map_err(|e| format!("setting {key:?}: `example` is {{\"text\", \"style\"}}: {e}"))?;
if e.text.is_empty() || e.text.len() > 300 {
return Err(format!("setting {key:?}: `example.text` is 1–300 bytes"));
}
if e.style.len() > 32 || !e.style.chars().all(|c| c.is_ascii_lowercase()) {
return Err(format!("setting {key:?}: `example.style` is a lowercase style name"));
}
Some(e)
}
};
let s = SettingSchema {
key: key.to_owned(),
kind: kind.to_owned(),
@@ -489,7 +464,6 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
group: text("group", MAX_TITLE)?,
example,
items,
properties,
};
@@ -743,7 +717,6 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
#[serde(default)]
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
@@ -755,19 +728,13 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
/// icons still win).
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
if d.patterns.is_empty() {
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
} else {
file_patterns(&d.patterns, &what)?;
}
file_patterns(&d.patterns, &what)?;
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
@@ -776,16 +743,8 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
let revision = blake3::hash(&bytes).to_hex().to_string();
let json = serde_json::json!({
"id": d.id,
"patterns": d.patterns,
"dark": d.icon_dark.is_some(),
"revision": revision,
});
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
let mut out = UiDecl::new(d.id.clone(), label, json);
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
out.files = files;
out.assets = assets;
Ok(out)
+10 -22
View File
@@ -447,11 +447,6 @@ impl Host {
.collect();
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
// A repository folder moved or restored into the project: no `.git` event is
// written inside it before the watcher sees the new directory, its creation is
// the signal. A `git clone` / `git init` inside the project already arrives
// through the `.git` paths above; this covers the whole tree being brought in.
git_dirs |= batch.created.iter().any(|p| p.join(".git").exists());
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
}
@@ -680,10 +675,9 @@ impl Host {
agents.set_new_task_approvals_hook(Arc::new(move || settings.effective().agents.new_task_approvals));
}
let approvals = self.mcp_approvals.clone();
let secrets = self.agent_secrets.clone();
let root = root.to_path_buf();
agents.set_mcp_servers_hook(Box::new(move || {
crate::ops::agent::session_mcp_servers(&settings, &approvals, &secrets, &root)
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
}));
(db, http_history, agents)
}
@@ -869,20 +863,6 @@ fn scan_repositories(
sink: &SharedSink,
build_cache: &Path,
) {
match discover_repositories(root, settings, build_cache) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
/// The repository containing `root` and those nested below it or below a content root.
/// The project-open scan and the watcher's rescan share these skip rules.
fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Path) -> IdeResult<Repos> {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
@@ -894,7 +874,15 @@ fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Pa
};
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
Repos::discover_roots(&roots, &skip)
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
impl Project {
+1 -2
View File
@@ -270,8 +270,7 @@ impl Dispatcher {
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
ops::register_all(&mut d);
d
}
-1
View File
@@ -475,7 +475,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_status_paths"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
+1 -178
View File
@@ -112,16 +112,6 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
assert_eq!(found["more"], true);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
assert!(all["items"][0].get("contextAfter").is_none());
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
assert_eq!(before["items"][0]["lineText"], "needle needle");
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
assert!(bad.contains("context must be"), "{bad}");
let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
@@ -231,11 +221,7 @@ async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
.iter()
.rev()
.find_map(|event| match event {
// Undoable like a refactoring, but marked as the agent's, so it isn't announced as one.
IdeEvent::WorkspaceEditApplied { id, by_agent, .. } => {
assert!(*by_agent, "an MCP edit is the agent's");
Some(*id)
}
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
@@ -467,166 +453,3 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
assert!(!format!("{result:?}").contains("denied"), "{result:?}");
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
}
#[tokio::test(flavor = "multi_thread")]
async fn check_files_says_why_a_file_could_not_be_checked() {
let h = Harness::new();
let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await;
assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}");
h.trust().await;
let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await;
assert_eq!(files["files"][0]["filePath"], "src/a.txt");
assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}");
assert_eq!(files["files"][0]["problems"], json!([]));
assert_eq!(files["files"][1]["status"], "failed", "{files}");
assert!(files["files"][1]["error"].is_string());
// No cap of the tool's own: a very long timeout is taken as given, without overflowing.
let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await;
assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}");
let none = h.error("check_files", json!({"filePaths": []})).await;
assert!(none.contains("at least one file"), "{none}");
}
/// Answers `run_command`'s window requests the way the Run tool window does: starts the
/// agent tab's configuration (shell from the default) and says whether it started. Returns
/// the configurations it started, for reruns.
#[cfg(unix)]
fn run_tool_window(h: &Harness) -> Arc<std::sync::Mutex<Vec<ide_run::RunConfig>>> {
let started = Arc::new(std::sync::Mutex::new(Vec::new()));
let (host, events, out) = (h.host.clone(), h.events.clone(), started.clone());
tokio::spawn(async move {
let mut handled = std::collections::HashSet::new();
loop {
let request = events.lock().unwrap().iter().find_map(|e| match e {
IdeEvent::McpRequest {
id,
request: ide_core::event::McpUiRequest::AgentRun { name, command, cwd, env },
} if !handled.contains(id) => Some((*id, name.clone(), command.clone(), cwd.clone(), env.clone())),
_ => None,
});
let Some((id, name, command, cwd, env)) = request else {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
continue;
};
handled.insert(id);
let mut config = ide_run::RunConfig {
id: format!("mcp-agent:{name}"),
name: name.clone(),
kind: "shell".into(),
command,
args: Vec::new(),
cwd: Some(cwd),
env,
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: true,
extras: Default::default(),
};
config.extras.params.insert(ide_host::mcp::AGENT_PARAM.into(), name);
config.extras.target = Some(String::new());
out.lock().unwrap().push(config.clone());
let result = ide_host::ops::run::start(&host, "main", config, Arc::new(|_| {})).await;
let error = result.err().map(|e| e.message);
let _ = host.mcp_requests.answer("main", id, error.is_none(), error);
}
});
started
}
#[cfg(unix)]
fn agent_process(h: &Harness, name: &str) -> ide_core::RunId {
let id = format!("mcp-agent:{name}");
h.host.processes.list().into_iter().filter(|r| r.config_id == id && r.running).map(|r| r.id).max().unwrap()
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
let h = Harness::new();
h.trust().await;
h.host.mcp_requests.set_ready("main", true);
h.host
.global_settings
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
.unwrap();
let started = run_tool_window(&h);
// A command that finishes within the wait, in a tab named with the prefix.
let done = h.json("run_command", json!({"name": "check", "command": "echo hel''lo; exit 3"})).await;
assert_eq!(done["name"], "_agent_check", "{done}");
assert_eq!(done["state"], "failed", "{done}");
assert_eq!(done["exitCode"], 3);
assert_eq!(done["startedBy"], "agent");
assert!(done["output"].as_str().unwrap().contains("hello"), "{done}");
assert!(done.get("interventions").is_none());
// The finished name runs the next command; a running one is refused, never stopped.
let running =
h.json("run_command", json!({"name": "_agent_check", "command": "echo ready; sleep 30", "timeout": 0})).await;
assert_eq!(running["state"], "running", "{running}");
let matched = h.json("wait_for_run", json!({"name": "check", "pattern": "(?m)^ready", "timeout": 10000})).await;
assert_eq!(matched["waitEnded"], "matched", "{matched}");
let refused = h.error("run_command", json!({"name": "check", "command": "true"})).await;
assert!(refused.contains("still running `echo ready; sleep 30`"), "{refused}");
assert!(refused.contains("Last output:\nready"), "the refusal quotes the last output: {refused}");
// What the user types (a line from the Run console's input) ends a wait at once, with advice for the agent.
let process = agent_process(&h, "_agent_check");
ide_host::ops::run::stdin(&h.host, process, "yes\n").unwrap();
let typed = h.json("wait_for_run", json!({"name": "check", "timeout": 10000})).await;
assert_eq!(typed["waitEnded"], "userIntervened", "{typed}");
assert_eq!(typed["interventions"][0]["message"], "User input: yes");
assert!(typed["advice"].as_str().unwrap().contains("Do not stop the process"));
let input = h.json("send_run_input", json!({"name": "check", "text": "abc", "keys": ["Enter"]})).await;
assert!(input.get("interventions").is_none(), "the agent's own input is no intervention: {input}");
let stopped = h.json("stop_run", json!({"name": "check", "force": true, "timeout": 10000})).await;
assert_eq!(stopped["state"], "stoppedByAgent", "{stopped}");
assert!(stopped.get("interventions").is_none());
// The user's Stop, rerun and closing the tab are reported with their time.
h.json("run_command", json!({"name": "server", "command": "sleep 30", "timeout": 0})).await;
ide_host::ops::run::stop(&h.host, "main", agent_process(&h, "_agent_server")).unwrap();
let stop = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
assert!(stop["interventions"][0]["message"].as_str().unwrap().starts_with("The user stopped this command at"));
let ended = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
assert_eq!(ended["state"], "stoppedByUser", "{ended}");
let config = started.lock().unwrap().last().cloned().unwrap();
ide_host::ops::run::start(&h.host, "main", config, Arc::new(|_| {})).await.unwrap();
let status = h.json("get_run_status", json!({"name": "server"})).await;
assert_eq!(status["startedBy"], "user", "{status}");
assert!(status["interventions"][0]["message"].as_str().unwrap().starts_with("The user reran this command at"));
h.json("stop_run", json!({"name": "server", "force": true, "timeout": 10000})).await;
h.host.mcp_agent_runs.closed(&h.root, "_agent_server");
let closed = h.json("get_run_status", json!({"name": "server"})).await;
assert!(closed["tabClosedAt"].is_string(), "{closed}");
assert!(closed["interventions"][0]["message"].as_str().unwrap().starts_with("The user closed this tab at"));
let log = h.json("read_run_output", json!({"name": "check", "maxLinesCount": 20})).await;
assert!(log["output"].as_str().unwrap().contains("ready"), "{log}");
let from_start = h.json("read_run_output", json!({"name": "check", "offset": 0})).await;
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
let runs = h.json("list_runs", json!({})).await;
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
// Pipes: plain lines, cleaned of escapes and cut to maxLinesCount (the end stays by default).
let printf = "printf '\\033[31mone\\033[0m\\ntwo\\nthree\\n'";
let lines =
h.json("run_command", json!({"name": "lines", "command": printf, "maxLinesCount": 2, "timeout": 10000})).await;
assert_eq!(lines["output"], "<<<...content truncated...>>>\ntwo\nthree\n", "{lines}");
assert_eq!(lines["truncatedLines"], 1);
let head = h.json("read_run_output", json!({"name": "lines", "maxLinesCount": 1, "truncateMode": "END"})).await;
assert_eq!(head["output"], "one\n<<<...content truncated...>>>\n", "{head}");
// Ctrl+C stops the command as stop_run does; terminal keys have no meaning on pipes.
h.json("run_command", json!({"name": "interrupt", "command": "sleep 30", "timeout": 0})).await;
let key = h.error("send_run_input", json!({"name": "interrupt", "keys": ["Up"]})).await;
assert!(key.contains("runs on pipes"), "{key}");
h.json("send_run_input", json!({"name": "interrupt", "keys": ["Ctrl+C"]})).await;
let interrupted = h.json("wait_for_run", json!({"name": "interrupt", "timeout": 10000})).await;
assert_eq!(interrupted["state"], "stoppedByAgent", "{interrupted}");
}
-133
View File
@@ -1,133 +0,0 @@
//! Values of the user's MCP servers' environment variables and HTTP headers live in the
//! credential store; `settings.toml` keeps only their names. A project's own servers keep
//! their values in the project's file, where the project shares them.
use ide_agent::{McpKeyValue, McpScope, McpServer, McpServerInput};
use ide_core::NullSink;
use ide_core::settings::McpTransport;
use ide_host::{Host, HostConfig};
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const VALUE: &str = "mcp-value-0f3c9a";
fn host(dir: &Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: dir.join("config"),
data_dir: dir.join("data"),
local_data_dir: dir.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn project(tmp: &tempfile::TempDir) -> PathBuf {
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
root
}
fn open(host: &Host, root: &Path) {
host.trust.decide(root, true, false).unwrap();
host.open_project("main", root).unwrap();
}
fn header(servers: &[McpServer], id: &str) -> String {
let server = servers.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} in {servers:?}"));
server.headers.iter().find(|h| h.name == "Authorization").map(|h| h.value.clone()).unwrap_or_default()
}
fn input(name: &str, scope: McpScope) -> McpServerInput {
McpServerInput {
id: None,
name: name.into(),
scope,
transport: McpTransport::Http,
enabled: true,
command: None,
args: vec![],
env: vec![],
url: Some("https://mcp.example/v1".into()),
headers: vec![McpKeyValue { name: "Authorization".into(), value: format!("Bearer {VALUE}") }],
agents: vec![],
}
}
const SERVER_WITH_VALUE: &str = r#"
[agents.mcpServers.remote]
transport = "http"
enabled = true
url = "https://mcp.example/v1"
headers = { Authorization = "Bearer mcp-value-0f3c9a" }
"#;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_server_values_stay_out_of_the_user_settings() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = project(&tmp);
let host = host(tmp.path());
open(&host, &root);
for (name, scope) in [("user-server", McpScope::User), ("repo-server", McpScope::Workspace)] {
ide_host::ops::agent::mcp_save(&host, "main", input(name, scope)).unwrap();
}
host.close_project("main");
// Only the project's own file holds a value; nothing in the user's config or data does.
let found = files_containing(tmp.path(), VALUE);
assert!(found.len() == 1 && found[0].ends_with(Path::new(".openidea/settings.toml")), "{found:?}");
let host = self::host(tmp.path());
open(&host, &root);
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
assert_eq!(header(&servers, "user::user-server"), format!("Bearer {VALUE}"));
assert_eq!(header(&servers, "workspace::repo-server"), format!("Bearer {VALUE}"));
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn values_in_user_settings_move_to_the_credential_store() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = project(&tmp);
std::fs::create_dir_all(tmp.path().join("config")).unwrap();
let saved = SERVER_WITH_VALUE.replace("Authorization =", "X-Saved = \"\", Authorization =");
std::fs::write(tmp.path().join("config/settings.toml"), saved).unwrap();
ide_host::agent_secrets::AgentSecrets::new(&tmp.path().join("data"))
.set("mcp/user/remote/header/X-Saved", "saved-earlier")
.unwrap();
let host = host(tmp.path());
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
open(&host, &root);
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
assert_eq!(header(&servers, "user::remote"), format!("Bearer {VALUE}"));
let remote = servers.iter().find(|s| s.id == "user::remote").unwrap();
assert!(remote.headers.iter().any(|h| h.name == "X-Saved" && h.value == "saved-earlier"), "{remote:?}");
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_projects_own_values_stay_in_its_file() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = project(&tmp);
let file = root.join(".openidea/settings.toml");
std::fs::write(&file, SERVER_WITH_VALUE).unwrap();
let host = host(tmp.path());
open(&host, &root);
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
assert_eq!(std::fs::read_to_string(&file).unwrap(), SERVER_WITH_VALUE, "reading leaves the project file alone");
// Saving the servers keeps the project's values in the project's file for everyone else.
let servers = ide_host::ops::agent::mcp_set_enabled(&host, "main", "workspace::remote".into(), false).unwrap();
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
assert!(std::fs::read_to_string(&file).unwrap().contains(VALUE));
assert_eq!(files_containing(&tmp.path().join("data"), VALUE), Vec::<PathBuf>::new());
host.close_project("main");
}
@@ -430,61 +430,3 @@ async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window
host.shutdown().await;
set_editor_providers(None);
}
/// A host with a project open: `problems` and `decorations` need a workspace.
fn harness(root: &Path) -> Arc<Host> {
let host = Arc::new(Host::new(HostConfig {
config_dir: root.join("config"),
data_dir: root.join("data"),
local_data_dir: root.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", root).unwrap();
host
}
fn plugins_on(host: &Arc<Host>, root: &Path) -> PluginHost {
let plugins = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(host)))));
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
plugins
}
#[tokio::test(flavor = "multi_thread")]
async fn a_plugin_reads_back_the_diagnostics_the_host_recorded() {
let tmp = tempfile::tempdir().unwrap();
let host = harness(tmp.path());
let plugins = plugins_on(&host, tmp.path());
let path = tmp.path().join("a.txt");
std::fs::write(&path, "text").unwrap();
// `publish` goes through the host's recording sink into the snapshot a plugin reads.
let invocation = || Invocation { client: Some("main".into()), ..Invocation::default() };
plugins.run_command(ID, "publish", Some(path.to_str().unwrap()), invocation()).unwrap();
let problems = plugins.run_command(ID, "problems", None, invocation()).unwrap().unwrap();
assert!(problems.contains("a.txt"), "problems returned {problems:?}");
assert!(problems.ends_with(":1"), "one diagnostic from its producer, got {problems:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_line_end_annotation_takes_the_typo_and_weak_styles() {
let tmp = tempfile::tempdir().unwrap();
let host = harness(tmp.path());
let plugins = plugins_on(&host, tmp.path());
let path = tmp.path().join("a.txt");
std::fs::write(&path, "text").unwrap();
// `decorate` publishes both styles; the host drops an unknown style silently, so a
// rejection here means one of the two is not a known decoration style.
plugins
.run_command(
ID,
"decorate",
Some(path.to_str().unwrap()),
Invocation { client: Some("main".into()), ..Invocation::default() },
)
.unwrap();
}
@@ -99,21 +99,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
let toml_path = root.join("a.toml");
std::fs::write(&toml_path, "k = 1\n").unwrap();
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
assert_eq!(
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
.await
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Unavailable
);
let sql_path = root.join("q.sql");
std::fs::write(&sql_path, "SELECT 1;").unwrap();
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
assert_eq!(
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
.parameters,
+10 -19
View File
@@ -139,7 +139,7 @@ async fn text_search_uses_overlay_excludes_utf16_and_limits() {
}
#[tokio::test(flavor = "multi_thread")]
async fn problems_come_from_the_host_snapshot_whatever_window_published_them() {
async fn problems_share_project_reports_and_keep_sources_separate() {
let f = Harness::new();
let path = f.root.join("a.txt");
std::fs::write(&path, "text").unwrap();
@@ -153,30 +153,21 @@ async fn problems_come_from_the_host_snapshot_whatever_window_published_them() {
deprecated: false,
server: None,
};
// Producers publish per (file, producer) through the event sink, like the
// language servers and the spelling check do.
let publish = |host: &Host, source: &str, items: Vec<Diagnostic>| {
host.project("main").unwrap().sink.emit(ide_core::IdeEvent::Diagnostics {
f.host.mcp_reports.report_problems(
"second",
true,
vec![ide_host::mcp::FileProblems {
path: path.clone().into(),
source: source.into(),
items,
});
};
publish(&f.host, "rust-analyzer", vec![diagnostic("lsp")]);
publish(&f.host, "build", vec![diagnostic("plugin")]);
// A file outside the window's project is visible too: annotations follow the file.
let outside = f.host.diagnostics.problems().len();
items: vec![diagnostic("lsp"), diagnostic("plugin")],
}],
);
let workspace = f.workspace();
let files = workspace.problems("main", Some(&path)).unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
assert_eq!(outside, 2);
// A full replacement per producer clears only that producer.
publish(&f.host, "rust-analyzer", vec![]);
let files = workspace.problems("main", None).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].source, "build");
f.host.mcp_reports.forget_window("second");
assert!(workspace.problems("main", None).unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
+2 -121
View File
@@ -2554,8 +2554,7 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
f.install(&official("git-toolbox"));
// Focusing an editor wakes it to show the branch (nothing fetched yet).
let now = Instant::now();
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let item = |host: &PluginHost| {
@@ -2610,82 +2609,6 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
assert!(err.contains("Restricted Mode"), "{err}");
}
/// Git Toolbox in a workspace window whose container folder is not a
/// repository (every member is its own clone): the status bar follows the
/// focused file's repository, and the scheduled fetch covers every member.
#[test]
fn official_git_toolbox_follows_members_in_a_workspace() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
let tmp = tempfile::tempdir().unwrap();
// A remote with `main`, cloned by member `a` (at `main`) and member `b`
// (on its own branch, to tell the two apart in the status bar).
let remote = tmp.path().join("remote.git");
std::fs::create_dir(&remote).unwrap();
git(&remote, &["init", "--bare", "-b", "main"]);
let seed = tmp.path().join("seed");
std::fs::create_dir(&seed).unwrap();
git(&seed, &["init", "-b", "main"]);
std::fs::write(seed.join("seed.txt"), "seed\n").unwrap();
git(&seed, &["add", "."]);
git(&seed, &["commit", "-m", "one"]);
git(&seed, &["remote", "add", "origin", remote.to_str().unwrap()]);
git(&seed, &["push", "-u", "origin", "main"]);
let member_a = tmp.path().join("member-a");
git(tmp.path(), &["clone", remote.to_str().unwrap(), member_a.to_str().unwrap()]);
let member_a = dunce::canonicalize(member_a).unwrap();
let member_b = tmp.path().join("member-b");
git(tmp.path(), &["clone", remote.to_str().unwrap(), member_b.to_str().unwrap()]);
let member_b = dunce::canonicalize(member_b).unwrap();
git(&member_b, &["checkout", "-b", "feature"]);
git(&member_b, &["push", "-u", "origin", "feature"]);
// The window's project is the container; the members are attached to it.
f.workspace.other_roots.lock().extend([member_a.clone(), member_b.clone()]);
f.install(&official("git-toolbox"));
let item = |host: &PluginHost| {
host.status_items().into_iter().find(|(p, id, _)| p == "openidea.git-toolbox" && id == "branch")
};
let now = Instant::now();
let focus = |path: &Path| {
f.host
.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(path.to_string_lossy().into_owned()))]);
f.host.flush_events_at(now + Duration::from_secs(3));
f.host.wait_events(Duration::from_secs(10));
};
// Focusing a file of member `a` shows that member's branch: the window's
// project is the container, which is no repository.
focus(&member_a.join("seed.txt"));
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main".into()));
// Fetch Now fetches the shown member; the container is not a repository.
f.run("openidea.git-toolbox", "fetch", None).unwrap();
let fetched = stored(&f, "openidea.git-toolbox");
assert!(
fetched.iter().any(|(key, _)| key == &format!("last-fetch:{}", to_ipc(&member_a))),
"{member_a:?}: {fetched:?}"
);
assert_eq!(fetched.iter().filter(|(key, _)| key.starts_with("last-fetch:")).count(), 1, "{fetched:?}");
// Switching to the other member follows it.
focus(&member_b.join("seed.txt"));
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()));
// Idle: reclaimed; the schedule then wakes it to fetch every member (the
// container is skipped: it has no repository).
std::thread::sleep(Duration::from_millis(100));
f.host.tick();
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()), "the item stays while it sleeps");
f.take_events();
f.host.tick_at(Instant::now() + Duration::from_secs(301));
assert!(f.host.wait_events(Duration::from_secs(30)));
let fetched = stored(&f, "openidea.git-toolbox");
for member in [&member_a, &member_b] {
assert!(
fetched.iter().any(|(key, _)| key == &format!("last-fetch:{}", to_ipc(member))),
"{member:?}: {fetched:?}"
);
}
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("feature".into()));
}
/// A tiny HTTP server: each request is answered after `delay(n)` (n counts
/// requests) with `body`. Returns its port and the request bodies it got.
fn http_server(
@@ -2993,18 +2916,13 @@ fn official_csv_editor_edits_the_shared_document() {
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
// Settings: without a header row every record is a row.
let mut values = serde_json::Map::new();
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
// Undeclared views are refused before any code runs.
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
@@ -3760,43 +3678,6 @@ path = "templates/new.draw"
assert!(f.host.web_page("web-demo", "draw").is_err());
}
#[test]
fn file_icons_without_patterns_require_api_1_8_and_project_assets() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("dynamic-icons");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(pkg.join("icons/helm-dir.svg"), r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>"#)
.unwrap();
let write_manifest = |api: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "dynamic-icons"
name = "Dynamic Icons"
version = "0.1.0"
engines.openidea = "{api}"
[[contributes.fileIcons]]
id = "helm-dir"
icon = "icons/helm-dir.svg"
"#
),
)
.unwrap();
};
write_manifest("^1.7");
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("1.8"), "{err:?}");
write_manifest("^1.8");
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let assets = f.host.assets("dynamic-icons", "fileIcon/helm-dir/").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<circle"));
}
#[test]
fn file_templates_fixture_installs_without_write_capabilities() {
let f = fixture(Limits::default());
-71
View File
@@ -252,77 +252,6 @@ async fn maven_sync() {
assert!(!junit.children.is_empty(), "{junit:?}");
}
/// The launch classpath must come from the runner JDK, not the JDK that happened
/// to load the language-server model: JDK-activated profiles can select a
/// different dependency classifier. Use real Maven so this covers the scenario
/// rather than only the probe arguments.
#[test]
#[ignore = "project smoke: needs Maven and a JDK"]
fn maven_launch_classpath_uses_the_runner_jdk_classifier() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let java_home = PathBuf::from(std::env::var("JAVA_HOME").expect("JAVA_HOME"));
let release = std::fs::read_to_string(java_home.join("release")).unwrap();
let version = release
.lines()
.find_map(|line| line.strip_prefix("JAVA_VERSION=\""))
.and_then(|line| line.strip_suffix('"'))
.expect("JAVA_VERSION in release");
let major = version.strip_prefix("1.").unwrap_or(version).split(['.', '_']).next().unwrap().parse::<u32>().unwrap();
let jar = java_home.join(if cfg!(windows) { "bin/jar.exe" } else { "bin/jar" });
let jars = root.join("libs");
let staging = root.join("empty");
std::fs::create_dir_all(&jars).unwrap();
std::fs::create_dir_all(&staging).unwrap();
for name in ["api-jdk8.jar", "api-jdk9.jar"] {
let status = std::process::Command::new(&jar)
.args(["cf"])
.arg(jars.join(name))
.arg(".")
.current_dir(&staging)
.status()
.unwrap();
assert!(status.success());
}
std::fs::write(
root.join("pom.xml"),
r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId><artifactId>jdk-classifier</artifactId><version>1</version>
<profiles>
<profile><id>jdk8</id><activation><jdk>1.8</jdk></activation><dependencies>
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk8</classifier>
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk8.jar</systemPath></dependency>
</dependencies></profile>
<profile><id>jdk9</id><activation><jdk>[9,)</jdk></activation><dependencies>
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk9</classifier>
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk9.jar</systemPath></dependency>
</dependencies></profile>
</profiles>
</project>"#,
)
.unwrap();
let cache = root.join("cache");
let mut model = ide_build::load(root, &cache, None);
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), java_home.to_string_lossy().into_owned());
}
let classpath = ide_build::compile::module_classpath(
root,
&model,
root,
false,
None,
&root.join("classpath-cache"),
&ide_core::CancellationToken::new(),
)
.unwrap();
let expected = if major <= 8 { "api-jdk8.jar" } else { "api-jdk9.jar" };
let other = if major <= 8 { "api-jdk9.jar" } else { "api-jdk8.jar" };
assert!(classpath.contains(expected), "{classpath}");
assert!(!classpath.contains(other), "{classpath}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
+16 -49
View File
@@ -367,7 +367,6 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
use ide_core::{Diagnostic, Pos, Range, Severity};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let tmp = tempfile::tempdir().unwrap();
let desktop = Arc::new(
@@ -427,70 +426,42 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
"severity": "warning", "message": message, "source": "remote-check", "code": null
}]})
};
let publish = |path: &Path, source: &str, message: Option<&str>| {
let items = message
.map(|message| Diagnostic {
range: Range::new(Pos::new(0, 1), Pos::new(0, 3)),
severity: Severity::Warning,
message: message.into(),
source: Some(source.into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.into_iter()
.collect();
host.project("main").unwrap().sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: source.into(),
items,
});
};
let files = json!([
problem(&remote_file, "remote warning"),
problem(&other_file, "other warning"),
problem(&outside_file, "outside warning")
]);
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
// UI reports are not the source of the plugin's host-owned snapshot.
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
publish(&remote_file, "remote-check", Some("remote warning"));
publish(&other_file, "remote-check", Some("other warning"));
publish(&outside_file, "remote-check", Some("outside warning"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("other warning"), "{problems}");
assert!(problems.contains("outside warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
for path in ["remote.txt", remote_file.to_str().unwrap()] {
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(!problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
}
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
publish(&remote_file, "second-check", Some("second warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
publish(&remote_file, "remote-check", None);
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(!problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
publish(&remote_file, "second-check", Some("replacement warning"));
invoke(
&peer,
"mcp_report_problems",
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
)
.await
.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("replacement warning"));
assert!(!problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
publish(&remote_file, "second-check", None);
publish(&other_file, "remote-check", None);
publish(&outside_file, "remote-check", None);
assert!(!problems.as_str().unwrap().contains("other warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
@@ -542,11 +513,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
stream.read_to_string(&mut response).await.unwrap();
assert!(response.contains("You can close this tab"));
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
publish(&remote_file, "remote-check", Some("remote warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
invoke(&peer, "close_project", json!({})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
publish(&remote_file, "remote-check", Some("remote warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
peer.kill();
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
let plugin_host = plugins.host().clone();
@@ -566,11 +537,7 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
})
.await
.unwrap();
// Disconnect clears desktop reports, not the still-live host project snapshot.
let problems = result.as_deref().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("remote-check"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
assert_eq!(result.as_deref(), Some("[]"));
plugins.host().shutdown();
desktop.shutdown();
}
-115
View File
@@ -341,7 +341,6 @@ async fn local_deployment_runs_on_the_project_host() {
"db_save_sources",
"db_set_password",
"db_test",
"db_test_tunnel",
"db_set_ssh_secret",
"db_trust_ssh_host",
"db_connected",
@@ -482,117 +481,3 @@ async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
assert!(rpc::client_commands().contains(&"term_spawn"));
plugins.host().shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn renames_a_branch_with_the_clients_argument_names() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir(&root).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git").args(args).current_dir(&root).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
};
git(&["init", "-q", "-b", "main"]);
std::fs::write(root.join("a.txt"), "a\n").unwrap();
git(&["add", "."]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]);
git(&["branch", "feature"]);
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
invoke(&peer, "open_project", json!({"path": root})).await.unwrap();
invoke(&peer, "project_trust_set", json!({"path": root, "trusted": true, "folder": false})).await.unwrap();
// The argument names the desktop client sends (`vcsRenameBranch` in src/bindings.ts).
invoke(
&peer,
"vcs_rename_branch",
json!({"repo": null, "from": "feature", "to": "renamed", "unsetUpstream": false}),
)
.await
.unwrap();
assert_eq!(
git(&["branch", "--format=%(refname:short)", "--list"]).lines().collect::<Vec<_>>(),
["main", "renamed"]
);
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
/// A handler reads its arguments by the names the desktop client sends: `src/bindings.ts`
/// passes them in camelCase, and a name the client never sends reads as `null` (an
/// `Option` silently `None`, anything else an "invalid type: null" error).
#[test]
fn handlers_read_the_argument_names_the_client_sends() {
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let bindings = std::fs::read_to_string(repo.join("src/bindings.ts")).unwrap();
let mut sent = HashMap::<&str, BTreeSet<&str>>::new();
for part in bindings.split("__TAURI_INVOKE(\"").skip(1) {
let (command, rest) = part.split_once('"').unwrap();
let args = rest.strip_prefix(", {").and_then(|r| r.split_once('}')).map_or("", |(args, _)| args);
let names = args.split(',').map(|a| a.split(':').next().unwrap().trim()).filter(|a| !a.is_empty());
sent.insert(command, names.collect());
}
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let mut files = Vec::new();
sources(&repo.join("crates/ide-host/src"), &mut files);
let mut checked = 0;
let mut wrong = Vec::new();
for file in files {
let src = std::fs::read_to_string(&file).unwrap();
// `.add("name", …)`, also with the name on the next line.
let starts: Vec<usize> = src
.match_indices(".add(")
.map(|(i, m)| i + m.len())
.filter_map(|i| src[i..].trim_start().strip_prefix('"').map(|rest| src.len() - rest.len()))
.collect();
for (n, &start) in starts.iter().enumerate() {
let body = &src[start..starts.get(n + 1).copied().unwrap_or(src.len())];
let (command, body) = body.split_once('"').unwrap();
let Some(sent) = sent.get(command) else { continue };
checked += 1;
for reader in ["c.arg", "c.sender", "c.channel"] {
for (at, _) in body.match_indices(reader) {
let rest = &body[at + reader.len()..];
let rest = match rest.strip_prefix("::<") {
Some(generic) => &generic[generic.find(">(").unwrap() + 1..],
None => rest,
};
let Some(name) = rest.strip_prefix("(\"").and_then(|r| r.split_once('"')).map(|(n, _)| n) else {
continue;
};
if !sent.contains(name) {
wrong.push(format!("{command} reads `{name}`; the client sends {sent:?}"));
}
}
}
}
}
assert!(checked > 400, "found only {checked} handlers with client bindings");
assert!(wrong.is_empty(), "{wrong:#?}");
}
+1 -1
View File
@@ -183,7 +183,7 @@ async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
host.remote.test_connection(&connection, "/projects").await.unwrap();
let local = tmp.path().join("local-deployment");
std::fs::create_dir(&local).unwrap();
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into(), None)
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
.await
.unwrap();
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
-1
View File
@@ -172,7 +172,6 @@ async fn restricted_vcs_commands_run_no_program_of_the_repository() {
}
let calls: Vec<Call> = vec![
call!("vcs_status", "", ops::vcs::status(host, w)),
call!("vcs_status_paths", "", ops::vcs::status_paths(host, w, vec![file.clone()])),
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
@@ -1,54 +0,0 @@
//! A repository folder moved into an open project is registered while the project is open:
//! the folder brings no `.git` event of its own, so the watcher's "created path" is what the
//! rescan has to answer to (the open scan already ran).
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use std::{path::Path, sync::Arc, time::Duration};
fn git_init(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
let status = std::process::Command::new("git").args(["init", "-q"]).current_dir(dir).status();
assert!(status.is_ok_and(|status| status.success()), "git init in {}", dir.display());
}
async fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while !done() {
assert!(tokio::time::Instant::now() < deadline, "timed out waiting for {what}");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn moving_a_repository_into_the_project_registers_it() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
git_init(&root);
// Prepared outside the project, as a restored or moved folder would be.
let prepared = base.join("prepared");
git_init(&prepared);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
// The scan on project open runs in the background: wait for it before moving anything in.
wait_until("the project's own repository", || project.repos().roots().len() == 1).await;
std::fs::rename(&prepared, root.join("moved")).unwrap();
wait_until("the moved repository to be registered", || {
project.repos().roots().iter().any(|r| r.ends_with("moved"))
})
.await;
}
-4
View File
@@ -31,7 +31,3 @@ criterion = { version = "0.5", default-features = false }
[[bench]]
name = "index"
harness = false
[[bench]]
name = "startup"
harness = false
-64
View File
@@ -1,64 +0,0 @@
//! `cargo bench -p ide-index --bench startup`: open a persisted 20k-file
//! project and wait for the index to become idle. This is the full warm-start
//! path (columnar snapshot, directory/file manifest, lazy redb).
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use ide_core::NullSink;
use ide_index::ProjectIndex;
use ide_language::{LanguageRegistry, Syntax};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const FILES: usize = 20_000;
fn project(root: &Path) {
for i in 0..FILES {
let dir = root.join(format!("mod{}/src", i % 50));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("File{i}.rs")), format!("fn symbol{i}() {{}}\n")).unwrap();
}
}
fn bench(c: &mut Criterion) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache = tmp.path().join("cache");
project(&root);
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
let syntax = Arc::new(Syntax::new());
let open = || {
ProjectIndex::open(
&root,
&cache,
Vec::<PathBuf>::new(),
vec![],
languages.clone(),
syntax.clone(),
Arc::new(NullSink),
)
.unwrap()
};
let first = open();
assert!(first.wait_idle(Duration::from_secs(30)));
drop(first);
let mut group = c.benchmark_group("startup_20k");
group.sample_size(10);
group.bench_function("reopen_wait_idle", |b| {
b.iter(|| {
let index = open();
assert!(index.wait_idle(Duration::from_secs(30)));
black_box(index.status().files)
})
});
group.finish();
}
criterion_group! {
name = benches;
config = Criterion::default().measurement_time(Duration::from_secs(3));
targets = bench
}
criterion_main!(benches);
+77 -767
View File
@@ -1,13 +1,4 @@
//! In-memory index: one slot per file, with compact query-time tables derived from it.
//!
//! Queries never walk the fat `FileEntry` structs: `HotFiles`, `HotDirs` and `SymTable` hold
//! only the fields a scan needs, packed in dense rows with interned strings, so a query
//! streams contiguous memory instead of chasing `Box<[Sym]>` / `Box<str>` pointers.
//! They are plain data, updated eagerly by `insert`/`remove` (every mutation needs `&mut
//! IndexData`), and all queries run under the `IndexData` read lock: a scan therefore sees
//! one consistent snapshot, and the only "invalidation" is the update itself. Space left
//! behind by replaced/removed entries is reclaimed by compaction inside `insert`/`remove`
//! once it dominates the table, and by `shrink`.
//! In-memory index: one slot per file, symbols stored with their file.
use ide_core::{Range, SymbolKind};
use serde::{Deserialize, Serialize};
@@ -24,19 +15,6 @@ pub(crate) fn char_mask(s: &str) -> u64 {
m
}
/// A relative index key must stay below the project root.
pub(crate) fn safe_rel(rel: &str) -> bool {
let mut any = false;
for component in Path::new(rel).components() {
if matches!(component, std::path::Component::Normal(_)) {
any = true;
} else {
return false;
}
}
any
}
#[inline]
pub(crate) fn mask_bit(b: u8) -> u64 {
match b.to_ascii_lowercase() {
@@ -55,40 +33,6 @@ pub(crate) fn mask_bit(b: u8) -> u64 {
}
}
/// A substring of an interned text arena.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Span {
pub start: u32,
pub len: u32,
}
impl Span {
/// A slice of nothing; used for optional fields (`None` containers).
pub(crate) const NONE: Span = Span { start: u32::MAX, len: 0 };
/// Appends `s` to `arena`, returning the span it will occupy.
///
/// Spans are `u32`-addressed and `NONE` uses `u32::MAX` as its start, so an
/// arena that reaches that bound cannot be represented. Fail loudly here
/// instead of truncating the offsets: a wrapped span would make [`Self::get`]
/// slice unrelated text, or panic far from the actual cause.
#[inline]
fn of(arena: &str, s: &str) -> Self {
let start = u32::try_from(arena.len()).expect("index text arena exceeded the 4 GiB span limit");
let len = u32::try_from(s.len()).expect("index text span exceeded the 4 GiB span limit");
assert!(
start != u32::MAX && start.checked_add(len).is_some(),
"index text arena exceeded the 4 GiB span limit"
);
Self { start, len }
}
#[inline]
pub(crate) fn get(self, arena: &str) -> &str {
&arena[self.start as usize..(self.start + self.len) as usize]
}
}
/// Persisted per-file record (postcard in redb, keyed by the relative path).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct FileRecord {
@@ -108,12 +52,12 @@ pub(crate) struct StoredSymbol {
pub range: Range,
}
/// A symbol resolved out of [`SymTable`].
pub(crate) struct SymRef<'a> {
pub name: &'a str,
pub(crate) struct Sym {
pub name: Box<str>,
pub kind: SymbolKind,
pub container: Option<&'a str>,
pub container: Option<Box<str>>,
pub range: Range,
pub mask: u64,
}
pub(crate) struct FileEntry {
@@ -129,34 +73,38 @@ pub(crate) struct FileEntry {
pub mtime: u64,
pub size: u64,
pub hash: [u8; 32],
/// Range of this file's symbols in [`IndexData::syms`].
pub sym_start: u32,
pub sym_len: u32,
pub symbols: Box<[Sym]>,
pub ignored: bool,
}
impl FileEntry {
/// Splits the record into the entry and its symbols; the symbols are flattened into
/// the index-wide [`SymTable`] by [`IndexData::insert`].
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> (Self, Vec<StoredSymbol>) {
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> Self {
let name_start = rel.rfind('/').map_or(0, |i| i + 1);
let name = &rel[name_start..];
let FileRecord { mtime, size, hash, symbols, ignored } = record;
let entry = Self {
let symbols = record
.symbols
.into_iter()
.map(|s| Sym {
mask: char_mask(&s.name),
name: s.name.into_boxed_str(),
kind: s.kind,
container: s.container.map(String::into_boxed_str),
range: s.range,
})
.collect();
Self {
name_start_chars: rel[..name_start].chars().count() as u32,
name_mask: char_mask(name),
rel_mask: char_mask(&rel),
name_start: name_start as u32,
path,
rel: rel.into_boxed_str(),
mtime,
size,
hash,
sym_start: 0,
sym_len: 0,
ignored,
};
(entry, symbols)
mtime: record.mtime,
size: record.size,
hash: record.hash,
symbols,
ignored: record.ignored,
}
}
pub(crate) fn name(&self) -> &str {
@@ -167,391 +115,38 @@ impl FileEntry {
&self.rel[..self.name_start as usize]
}
pub(crate) fn record(&self) -> FileRecord {
FileRecord {
mtime: self.mtime,
size: self.size,
hash: self.hash,
symbols: self
.symbols
.iter()
.map(|s| StoredSymbol {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
range: s.range,
})
.collect(),
ignored: self.ignored,
}
}
/// Whether the entry is out of date for `found` on disk.
pub(crate) fn stale_for(&self, found: &crate::filter::Found) -> bool {
self.mtime != found.mtime || self.size != found.size || self.ignored != found.ignored
}
}
/// The hot fields of one file slot, packed for the query scan; no pointers.
pub(crate) struct FileRow {
/// `rel` in [`HotFiles`]' arena.
pub rel: Span,
/// Byte offset of the file name inside `rel`.
pub name_start: u32,
pub sym_start: u32,
pub sym_len: u32,
/// Union of every symbol's [`char_mask`]: a selective symbol query can skip
/// the whole file before touching its symbol range.
pub sym_mask: u64,
pub rel_mask: u64,
pub name_mask: u64,
/// Mask of the directory part (`rel[..name_start]`), precomputed for path queries.
pub dir_mask: u64,
/// `rel` is pure ASCII, so nucleo can match it without a UTF-32 conversion.
pub ascii: bool,
/// The slot holds a file; free slots keep a row so scans line up with `IndexData::files`.
pub live: bool,
}
impl FileRow {
#[inline]
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
self.rel.get(arena)
}
#[inline]
pub(crate) fn name<'a>(&self, arena: &'a str) -> &'a str {
&self.rel.get(arena)[self.name_start as usize..]
}
#[inline]
pub(crate) fn dir<'a>(&self, arena: &'a str) -> &'a str {
&self.rel.get(arena)[..self.name_start as usize]
}
}
/// One dense row per file slot (same order and length as `IndexData::files`) plus the
/// text arena the rows point into.
#[derive(Default)]
pub(crate) struct HotFiles {
pub rows: Vec<FileRow>,
text: String,
dead_text: usize,
}
impl HotFiles {
#[inline]
pub(crate) fn text(&self) -> &str {
&self.text
}
#[inline]
pub(crate) fn row(&self, slot: usize) -> Option<&FileRow> {
self.rows.get(slot).filter(|row| row.live)
}
#[inline]
pub(crate) fn row_of_slot(&self, slot: u32) -> Option<&FileRow> {
self.row(slot as usize)
}
/// `rel` of a live slot; candidate slots always are.
#[inline]
pub(crate) fn rel(&self, slot: u32) -> &str {
self.rows[slot as usize].rel(&self.text)
}
fn set(&mut self, slot: u32, row: FileRow) {
if self.rows.len() as u32 == slot {
self.rows.push(row);
} else {
self.rows[slot as usize] = row;
}
}
fn append(&mut self, rel: &str) -> Span {
let span = Span::of(&self.text, rel);
self.text.push_str(rel);
span
}
/// Updates the row of an existing slot (replace) in place.
fn update(&mut self, slot: u32, e: &FileEntry, sym_mask: u64) {
let idx = slot as usize;
if self.rows[idx].rel.get(&self.text) != &*e.rel {
self.dead_text += self.rows[idx].rel.len as usize;
let span = self.append(&e.rel);
self.rows[idx].rel = span;
self.rows[idx].ascii = e.rel.is_ascii();
self.rows[idx].dir_mask = char_mask(e.dir());
}
let row = &mut self.rows[idx];
row.name_start = e.name_start;
row.name_mask = e.name_mask;
row.rel_mask = e.rel_mask;
row.sym_start = e.sym_start;
row.sym_len = e.sym_len;
row.sym_mask = sym_mask;
row.live = true;
}
fn remove(&mut self, slot: u32) {
let row = &mut self.rows[slot as usize];
self.dead_text += row.rel.len as usize;
row.rel = Span::NONE;
row.sym_start = 0;
row.sym_len = 0;
row.sym_mask = 0;
row.live = false;
}
fn compact(&mut self) {
let mut text = String::with_capacity(self.text.len() - self.dead_text);
for row in &mut self.rows {
if !row.live {
continue;
}
let rel = row.rel.get(&self.text);
row.rel = Span::of(&text, rel);
text.push_str(rel);
}
self.text = text;
self.dead_text = 0;
}
fn shrink(&mut self) {
self.compact();
self.rows.shrink_to_fit();
self.text.shrink_to_fit();
}
}
/// One folder holding indexed files, packed for the query scan.
pub(crate) struct DirRow {
/// `rel` in [`HotDirs`]' arena, including the trailing `/`.
pub rel: Span,
/// Byte offset of the last path segment inside the trimmed `rel`.
pub name_start: u32,
pub name_mask: u64,
/// Mask of `rel` without the trailing `/`.
pub rel_mask: u64,
pub path: PathBuf,
}
impl DirRow {
#[inline]
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
self.rel.get(arena)
}
}
#[derive(Default)]
pub(crate) struct HotDirs {
pub rows: Vec<DirRow>,
text: String,
dead: usize,
}
impl HotDirs {
#[inline]
pub(crate) fn text(&self) -> &str {
&self.text
}
fn push(&mut self, rel: &str, abs: &Path) -> u32 {
let span = Span::of(&self.text, rel);
self.text.push_str(rel);
let trimmed = &rel[..rel.len() - 1];
let name_start = trimmed.rfind('/').map_or(0, |i| i + 1);
self.rows.push(DirRow {
rel: span,
name_start: name_start as u32,
name_mask: char_mask(&trimmed[name_start..]),
rel_mask: char_mask(trimmed),
path: abs.to_owned(),
});
(self.rows.len() - 1) as u32
}
fn remove(&mut self, row: u32, dirs: &mut HashMap<Box<str>, (PathBuf, u32, u32)>) {
let idx = row as usize;
let last = self.rows.len() - 1;
self.dead += self.rows[idx].rel.len as usize;
self.rows.swap_remove(idx);
if idx != last {
let moved = self.rows[idx].rel.get(&self.text);
if let Some(dir) = dirs.get_mut(moved) {
dir.2 = idx as u32;
}
}
}
fn compact(&mut self) {
let mut text = String::with_capacity(self.text.len() - self.dead);
for row in &mut self.rows {
let rel = row.rel.get(&self.text);
row.rel = Span::of(&text, rel);
text.push_str(rel);
}
self.text = text;
self.dead = 0;
}
fn shrink(&mut self) {
self.compact();
self.rows.shrink_to_fit();
self.text.shrink_to_fit();
}
}
/// The scan-relevant fields of one symbol: its character mask and the name in the
/// arena. Interleaved so a scan touches one cache line per candidate instead of three
/// parallel arrays.
#[derive(Clone, Copy)]
pub(crate) struct SymHead {
pub mask: u64,
pub name: Span,
}
/// Index-wide symbol storage: one row per symbol in parallel arrays, with names and
/// containers interned in a single arena. Each file points at a contiguous range.
#[derive(Default)]
pub(crate) struct SymTable {
head: Vec<SymHead>,
container: Vec<Span>,
kind: Vec<SymbolKind>,
range: Vec<Range>,
text: String,
dead_rows: usize,
}
impl SymTable {
#[inline]
pub(crate) fn name(&self, i: u32) -> &str {
self.head[i as usize].name.get(&self.text)
}
#[inline]
pub(crate) fn mask(&self, i: u32) -> u64 {
self.head[i as usize].mask
}
#[inline]
pub(crate) fn container(&self, i: u32) -> Option<&str> {
let span = self.container[i as usize];
(span.start != u32::MAX).then(|| span.get(&self.text))
}
/// The mask + name rows of a file's contiguous symbol range.
#[inline]
pub(crate) fn heads(&self, start: u32, len: u32) -> &[SymHead] {
let (start, end) = (start as usize, start as usize + len as usize);
&self.head[start..end]
}
#[inline]
pub(crate) fn kind(&self, i: u32) -> SymbolKind {
self.kind[i as usize]
}
#[inline]
pub(crate) fn range(&self, i: u32) -> Range {
self.range[i as usize]
}
fn intern(&mut self, s: &str) -> Span {
let span = Span::of(&self.text, s);
self.text.push_str(s);
span
}
/// Appends a file's symbols; they stay contiguous. Returns the start index
/// and the union of their masks.
fn push(&mut self, symbols: &[StoredSymbol]) -> (u32, u64) {
let start = self.head.len() as u32;
let mut agg_mask = 0u64;
for s in symbols {
let mask = char_mask(&s.name);
agg_mask |= mask;
let name = self.intern(&s.name);
let container = match &s.container {
Some(c) => self.intern(c),
None => Span::NONE,
};
self.head.push(SymHead { mask, name });
self.container.push(container);
self.kind.push(s.kind);
self.range.push(s.range);
}
(start, agg_mask)
}
/// Whether `symbols` would flatten to exactly the range at `start`.
fn same(&self, start: u32, len: u32, symbols: &[StoredSymbol]) -> bool {
len as usize == symbols.len()
&& symbols.iter().enumerate().all(|(k, s)| {
let i = start + k as u32;
self.name(i) == s.name
&& self.container(i) == s.container.as_deref()
&& self.kind(i) == s.kind
&& self.range(i) == s.range
})
}
fn free(&mut self, _start: u32, len: u32) {
self.dead_rows += len as usize;
}
/// Rewrites the live rows in file order, reclaiming the space of replaced files.
/// `files` entries keep their range in sync; queries read rows, but persistence
/// and `FileEntry` accessors go through the entry.
fn compact(&mut self, rows: &mut [FileRow], files: &mut [Option<FileEntry>]) {
let mut text = String::with_capacity(self.text.len());
let mut head = Vec::with_capacity(self.head.len() - self.dead_rows);
let mut container = Vec::with_capacity(head.capacity());
let mut kind = Vec::with_capacity(head.capacity());
let mut range = Vec::with_capacity(head.capacity());
let mut live = 0u32;
for (slot, row) in rows.iter_mut().enumerate() {
if !row.live {
row.sym_start = 0;
continue;
}
let (start, end) = (row.sym_start, row.sym_start + row.sym_len);
row.sym_start = live;
if let Some(e) = files.get_mut(slot).and_then(Option::as_mut) {
e.sym_start = live;
}
for i in start..end {
let name_span = self.head[i as usize].name;
let name = name_span.get(&self.text);
head.push(SymHead { mask: self.head[i as usize].mask, name: Span::of(&text, name) });
text.push_str(name);
let container_span = self.container[i as usize];
if container_span.start == u32::MAX {
container.push(Span::NONE);
} else {
let c = container_span.get(&self.text);
container.push(Span::of(&text, c));
text.push_str(c);
}
kind.push(self.kind[i as usize]);
range.push(self.range[i as usize]);
live += 1;
}
}
self.head = head;
self.container = container;
self.kind = kind;
self.range = range;
self.text = text;
self.dead_rows = 0;
}
fn shrink(&mut self) {
self.head.shrink_to_fit();
self.container.shrink_to_fit();
self.kind.shrink_to_fit();
self.range.shrink_to_fit();
self.text.shrink_to_fit();
}
}
#[derive(Default)]
pub(crate) struct IndexData {
pub files: Vec<Option<FileEntry>>,
by_path: HashMap<PathBuf, u32>,
/// Every folder holding an indexed file at any depth, keyed by its `rel` with a
/// trailing `/`: its absolute path, the number of files below it, and its row in
/// [`HotDirs`].
pub dirs: HashMap<Box<str>, (PathBuf, u32, u32)>,
/// Compact query-time view of `files`, kept in sync by `insert`/`remove`.
pub hot: HotFiles,
/// Compact query-time view of `dirs`, kept in sync by `insert`/`remove`.
pub dirs_hot: HotDirs,
/// Flattened symbols of every file, kept in sync by `insert`/`remove`.
pub syms: SymTable,
/// trailing `/`: its absolute path and the number of files below it.
pub dirs: HashMap<Box<str>, (PathBuf, u32)>,
free: Vec<u32>,
pub file_count: u32,
pub symbol_count: u32,
@@ -566,185 +161,49 @@ impl IndexData {
self.by_path.get(path).copied()
}
/// Reserves the flat tables for a bulk warm-start hydration.
pub(crate) fn reserve(&mut self, files: usize, symbols: usize, dirs: usize) {
self.files.reserve(files);
self.by_path.reserve(files);
self.dirs.reserve(dirs);
self.hot.rows.reserve(files);
self.dirs_hot.rows.reserve(dirs);
self.syms.head.reserve(symbols);
self.syms.container.reserve(symbols);
self.syms.kind.reserve(symbols);
self.syms.range.reserve(symbols);
}
/// Hydrates a validated warm-start snapshot in one pass, without per-file
/// duplicate checks, ancestor walks or compaction checks.
pub(crate) fn load_bulk(
&mut self,
entries: Vec<(FileEntry, Vec<StoredSymbol>)>,
dirs: Vec<(Box<str>, PathBuf, u32)>,
) {
self.files = Vec::with_capacity(entries.len());
self.by_path = HashMap::with_capacity(entries.len());
self.hot = HotFiles::default();
self.dirs_hot = HotDirs::default();
self.syms = SymTable::default();
self.dirs = HashMap::with_capacity(dirs.len());
let symbols: usize = entries.iter().map(|(_, symbols)| symbols.len()).sum();
self.reserve(entries.len(), symbols, dirs.len());
self.symbol_count = 0;
for (slot, (mut entry, symbols)) in entries.into_iter().enumerate() {
let (sym_start, sym_mask) = self.syms.push(&symbols);
entry.sym_start = sym_start;
entry.sym_len = symbols.len() as u32;
self.symbol_count += entry.sym_len;
let rel = self.hot.append(&entry.rel);
self.hot.set(
slot as u32,
FileRow {
rel,
name_start: entry.name_start,
sym_start,
sym_len: entry.sym_len,
sym_mask,
rel_mask: entry.rel_mask,
name_mask: entry.name_mask,
dir_mask: char_mask(entry.dir()),
ascii: entry.rel.is_ascii(),
live: true,
},
);
self.by_path.insert(entry.path.clone(), slot as u32);
self.files.push(Some(entry));
}
self.file_count = self.files.len() as u32;
for (rel, path, count) in dirs {
let row = self.dirs_hot.push(&rel, &path);
self.dirs.insert(rel, (path, count, row));
}
self.free.clear();
}
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
FileRecord {
mtime: e.mtime,
size: e.size,
hash: e.hash,
symbols: self
.symbols_of(e)
.map(|s| StoredSymbol {
name: s.name.to_owned(),
kind: s.kind,
container: s.container.map(str::to_owned),
range: s.range,
})
.collect(),
ignored: e.ignored,
}
}
pub(crate) fn record_of(&self, path: &Path) -> Option<FileRecord> {
self.get(path).map(|e| self.record(e))
}
/// The symbols of one file, in declaration order.
pub(crate) fn symbols_of<'a>(&'a self, e: &'a FileEntry) -> impl Iterator<Item = SymRef<'a>> + 'a {
let syms = &self.syms;
(e.sym_start..e.sym_start + e.sym_len).map(move |i| SymRef {
name: syms.name(i),
kind: syms.kind(i),
container: syms.container(i),
range: syms.range(i),
})
}
pub(crate) fn insert(&mut self, mut entry: FileEntry, symbols: Vec<StoredSymbol>) {
let sym_len = symbols.len() as u32;
pub(crate) fn insert(&mut self, entry: FileEntry) {
let symbols = entry.symbols.len() as u32;
if let Some(&slot) = self.by_path.get(&entry.path) {
let old = self.files[slot as usize].as_ref().expect("indexed slots are live");
let old_len = old.sym_len;
// Rescans and saves that re-parse to the same symbols reuse the range; a
// same-path replacement then costs nothing in the symbol table.
let (sym_start, sym_mask) = if self.syms.same(old.sym_start, old_len, &symbols) {
(old.sym_start, self.hot.rows[slot as usize].sym_mask)
} else {
self.syms.free(old.sym_start, old_len);
self.syms.push(&symbols)
};
entry.sym_start = sym_start;
entry.sym_len = sym_len;
self.symbol_count = self.symbol_count - old_len + sym_len;
self.hot.update(slot, &entry, sym_mask);
self.files[slot as usize] = Some(entry);
self.compact_if_needed();
let old = self.files[slot as usize].replace(entry);
self.symbol_count -= old.map_or(0, |o| o.symbols.len() as u32);
self.symbol_count += symbols;
return;
}
let (sym_start, sym_mask) = self.syms.push(&symbols);
entry.sym_start = sym_start;
entry.sym_len = sym_len;
let slot = match self.free.pop() {
Some(slot) => slot,
Some(slot) => {
self.files[slot as usize] = Some(entry);
slot
}
None => {
self.files.push(None);
self.files.push(Some(entry));
(self.files.len() - 1) as u32
}
};
let path = entry.path.clone();
let (name_start, name_mask, rel_mask) = (entry.name_start, entry.name_mask, entry.rel_mask);
let rel = self.hot.append(&entry.rel);
self.hot.set(
slot,
FileRow {
rel,
name_start,
sym_start: entry.sym_start,
sym_len,
sym_mask,
rel_mask,
name_mask,
dir_mask: char_mask(&entry.rel[..name_start as usize]),
ascii: entry.rel.is_ascii(),
live: true,
},
);
self.files[slot as usize] = Some(entry);
let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default();
if let Some(e) = &self.files[slot as usize] {
for (rel, abs) in ancestors(e) {
if let Some(dir) = self.dirs.get_mut(rel) {
dir.1 += 1;
} else {
let row = self.dirs_hot.push(rel, abs);
self.dirs.insert(rel.into(), (abs.to_owned(), 1, row));
}
self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1;
}
}
self.by_path.insert(path, slot);
self.file_count += 1;
self.symbol_count += sym_len;
self.compact_if_needed();
self.symbol_count += symbols;
}
pub(crate) fn remove(&mut self, path: &Path) -> Option<FileEntry> {
let slot = self.by_path.remove(path)?;
let old = self.files[slot as usize].take()?;
self.free.push(slot);
self.hot.remove(slot);
self.syms.free(old.sym_start, old.sym_len);
for (rel, _) in ancestors(&old) {
let Some(dir) = self.dirs.get_mut(rel) else { continue };
dir.1 -= 1;
if dir.1 > 0 {
continue;
if let Some(d) = self.dirs.get_mut(rel) {
d.1 -= 1;
if d.1 == 0 {
self.dirs.remove(rel);
}
}
let Some((_, _, row)) = self.dirs.remove(rel) else { continue };
self.dirs_hot.remove(row, &mut self.dirs);
}
self.file_count -= 1;
self.symbol_count -= old.sym_len;
self.compact_if_needed();
self.symbol_count -= old.symbols.len() as u32;
Some(old)
}
@@ -754,19 +213,6 @@ impl IndexData {
doomed.into_iter().filter(|p| self.remove(p).is_some()).collect()
}
/// Reclaims the space of replaced/removed files once it dominates a table.
fn compact_if_needed(&mut self) {
if self.hot.dead_text > 1 << 16 && self.hot.dead_text * 2 > self.hot.text.len() {
self.hot.compact();
}
if self.syms.dead_rows > 4096 && self.syms.dead_rows * 2 > self.syms.head.len() {
self.syms.compact(&mut self.hot.rows, &mut self.files);
}
if self.dirs_hot.dead > 1 << 16 && self.dirs_hot.dead * 2 > self.dirs_hot.text.len() {
self.dirs_hot.compact();
}
}
/// Releases spare capacity (after large deletions).
pub(crate) fn shrink(&mut self) {
while matches!(self.files.last(), Some(None)) {
@@ -775,11 +221,6 @@ impl IndexData {
self.free.retain(|&s| s != last);
}
self.files.shrink_to_fit();
self.hot.rows.truncate(self.files.len());
self.hot.shrink();
self.dirs_hot.shrink();
self.syms.compact(&mut self.hot.rows, &mut self.files);
self.syms.shrink();
self.free.shrink_to_fit();
self.by_path.shrink_to_fit();
self.dirs.shrink_to_fit();
@@ -807,7 +248,7 @@ fn ancestors(e: &FileEntry) -> impl Iterator<Item = (&str, &Path)> {
mod tests {
use super::*;
fn entry(path: &str, symbols: usize) -> (FileEntry, Vec<StoredSymbol>) {
fn entry(path: &str, symbols: usize) -> FileEntry {
let record = FileRecord {
symbols: (0..symbols)
.map(|i| StoredSymbol {
@@ -822,118 +263,48 @@ mod tests {
FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record)
}
fn insert(d: &mut IndexData, path: &str, symbols: usize) {
let (e, s) = entry(path, symbols);
d.insert(e, s);
}
/// The compact tables must always mirror the entries they were derived from.
fn assert_consistent(d: &IndexData) {
assert_eq!(d.hot.rows.len(), d.files.len(), "one hot row per slot");
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), d.file_count as usize, "live rows are files");
for (slot, e) in d.files.iter().enumerate() {
let Some(e) = e else {
assert!(d.hot.row(slot).is_none(), "free slot {slot} has no row");
continue;
};
let row = d.hot.row(slot).unwrap_or_else(|| panic!("slot {slot} has a row"));
assert_eq!(row.rel(d.hot.text()), &*e.rel);
assert_eq!(row.name(d.hot.text()), e.name());
assert_eq!(row.dir(d.hot.text()), e.dir());
assert_eq!(row.name_mask, char_mask(e.name()));
assert_eq!(row.rel_mask, char_mask(&e.rel));
assert_eq!(row.dir_mask, char_mask(e.dir()));
assert_eq!(row.ascii, e.rel.is_ascii());
assert_eq!(row.sym_start, e.sym_start);
assert_eq!(row.sym_len, e.sym_len);
assert_eq!(row.sym_mask, d.symbols_of(e).fold(0u64, |m, s| m | char_mask(s.name)));
assert_eq!(d.syms.head.len(), d.syms.container.len());
assert_eq!(d.syms.head.len(), d.syms.kind.len());
assert_eq!(d.syms.head.len(), d.syms.range.len());
assert!(e.sym_len == 0 || (e.sym_start as usize) < d.syms.head.len());
assert!(e.sym_start + e.sym_len <= d.syms.head.len() as u32);
}
for (rel, (path, count, row)) in &d.dirs {
let row = &d.dirs_hot.rows[*row as usize];
assert_eq!(row.rel(d.dirs_hot.text()), &**rel);
assert_eq!(&row.path, path);
assert!(*count > 0);
let trimmed = &rel[..rel.len() - 1];
let name = &trimmed[row.name_start as usize..];
assert_eq!(row.name_mask, char_mask(name));
assert_eq!(row.rel_mask, char_mask(trimmed));
}
assert_eq!(d.dirs.len(), d.dirs_hot.rows.len());
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
}
#[test]
fn bulk_load_matches_incremental_insert() {
let mut incremental = IndexData::default();
insert(&mut incremental, "a/b.rs", 2);
insert(&mut incremental, "c.rs", 1);
assert_consistent(&incremental);
let mut bulk = IndexData::default();
bulk.load_bulk(vec![entry("a/b.rs", 2), entry("c.rs", 1)], vec![("a/".into(), PathBuf::from("/r/a"), 1)]);
assert_consistent(&bulk);
assert_eq!((bulk.file_count, bulk.symbol_count), (2, 3));
assert_eq!(bulk.hot.text(), incremental.hot.text());
assert_eq!(bulk.files.len(), incremental.files.len());
assert_eq!(bulk.slot(Path::new("/r/a/b.rs")), incremental.slot(Path::new("/r/a/b.rs")));
assert_eq!(bulk.dirs["a/"].1, 1);
}
#[test]
fn dirs_follow_inserts_and_removals() {
let mut d = IndexData::default();
insert(&mut d, "a/b/c.rs", 0);
insert(&mut d, "a/d.rs", 0);
insert(&mut d, "a/d.rs", 0);
insert(&mut d, "top.rs", 0);
d.insert(entry("a/b/c.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("top.rs", 0));
let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect();
dirs.sort();
assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]);
assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b"));
assert_consistent(&d);
d.remove(Path::new("/r/a/b/c.rs"));
assert!(!d.dirs.contains_key("a/b/"));
assert_consistent(&d);
d.remove_tree(Path::new("/r/a"));
assert!(d.dirs.is_empty());
assert_consistent(&d);
}
#[test]
fn insert_replace_remove_keep_counts() {
let mut d = IndexData::default();
insert(&mut d, "a/b.rs", 2);
insert(&mut d, "c.rs", 1);
d.insert(entry("a/b.rs", 2));
d.insert(entry("c.rs", 1));
assert_eq!((d.file_count, d.symbol_count), (2, 3));
insert(&mut d, "a/b.rs", 5);
d.insert(entry("a/b.rs", 5));
assert_eq!((d.file_count, d.symbol_count), (2, 6));
assert_consistent(&d);
assert!(d.remove(Path::new("/r/c.rs")).is_some());
assert_eq!((d.file_count, d.symbol_count), (1, 5));
assert_consistent(&d);
insert(&mut d, "d.rs", 0);
d.insert(entry("d.rs", 0));
assert_eq!(d.files.len(), 2, "slot reused");
assert_consistent(&d);
assert_eq!(d.remove_tree(Path::new("/r/a")), vec![PathBuf::from("/r/a/b.rs")]);
assert_eq!((d.file_count, d.symbol_count), (1, 0));
let e = d.get(Path::new("/r/d.rs")).unwrap();
assert_eq!((e.name(), e.dir()), ("d.rs", ""));
insert(&mut d, "e.rs", 1);
insert(&mut d, "f.rs", 1);
d.insert(entry("e.rs", 1));
d.insert(entry("f.rs", 1));
d.remove(Path::new("/r/e.rs"));
d.remove(Path::new("/r/f.rs"));
d.shrink();
assert_consistent(&d);
assert_eq!(d.files.len(), 2, "trailing free slot dropped");
assert_eq!(d.free, vec![0]);
insert(&mut d, "g.rs", 1);
d.insert(entry("g.rs", 1));
assert_eq!((d.file_count, d.files.len()), (2, 2));
assert_consistent(&d);
}
#[test]
@@ -941,65 +312,4 @@ mod tests {
assert_eq!(char_mask("ab") & char_mask("Bad"), char_mask("ab"));
assert_ne!(char_mask("z") & char_mask("abc"), char_mask("z"));
}
fn insert_symbols(d: &mut IndexData, path: &str, symbols: Vec<StoredSymbol>) {
let record = FileRecord { symbols, ..Default::default() };
let (e, s) = FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record);
d.insert(e, s);
}
fn symbol(n: &str) -> StoredSymbol {
StoredSymbol { name: n.to_owned(), kind: SymbolKind::Function, container: None, range: Range::default() }
}
#[test]
fn compaction_keeps_entries_and_records_in_sync() {
let mut d = IndexData::default();
insert(&mut d, "a.rs", 3);
insert(&mut d, "b/c.rs", 2);
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
d.shrink();
assert_consistent(&d);
assert_eq!(d.syms.head.len() as u32, d.symbol_count, "compaction drops dead symbol rows");
let names = |d: &IndexData, p: &str| -> Vec<String> {
let e = d.get(Path::new(p)).unwrap();
d.record(e).symbols.into_iter().map(|s| s.name).collect()
};
assert_eq!(names(&d, "/r/a.rs"), ["fresh"]);
assert_eq!(names(&d, "/r/b/c.rs"), ["s0", "s1"]);
assert_eq!(d.symbol_count, 3);
}
#[test]
fn large_replacements_reclaim_symbol_storage() {
let mut d = IndexData::default();
insert_symbols(&mut d, "a.rs", (0..5_000).map(|i| symbol(&format!("old{i}"))).collect());
assert_eq!(d.syms.head.len(), 5_000);
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
assert_eq!(d.syms.head.len(), 1, "dead symbol rows are reclaimed");
assert_eq!((d.symbol_count, d.file_count), (1, 1));
assert_consistent(&d);
}
#[test]
fn tree_removal_leaves_no_hot_rows_or_dirs() {
let mut d = IndexData::default();
insert(&mut d, "a/b/one.rs", 2);
insert(&mut d, "a/b/two.rs", 3);
insert(&mut d, "a/three.rs", 1);
insert(&mut d, "elsewhere/four.rs", 1);
assert_eq!(d.remove_tree(Path::new("/r/a")).len(), 3);
assert_consistent(&d);
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), 1);
assert!(d.hot.row(0).is_none(), "removed slots have no live row");
assert_eq!(d.dirs.len(), 1, "elsewhere/ only");
assert_eq!(d.dirs_hot.rows.len(), 1);
assert_eq!(d.symbol_count, 1);
d.shrink();
assert_eq!(d.syms.head.len() as u32, d.symbol_count);
// Slots are reusable and the new row replaces the tombstone.
insert(&mut d, "a/five.rs", 2);
assert_consistent(&d);
assert_eq!(d.file_count, 2);
}
}
-45
View File
@@ -138,22 +138,6 @@ impl PathFilter {
&self.config.0 == extra_roots && self.config.1 == excludes
}
/// Stable fingerprint of the persisted-scope configuration; a start-up
/// snapshot is only trusted when this still matches.
pub(crate) fn stamp(&self) -> String {
let mut stamp = format!("include_root={}\n", self.config.0.include_root);
for folder in &self.config.0.folders {
stamp.push_str(&folder.to_string_lossy());
stamp.push('\n');
}
stamp.push_str("--\n");
for exclude in &self.config.1 {
stamp.push_str(exclude);
stamp.push('\n');
}
stamp
}
pub(crate) fn reload(&self) -> Self {
Self::new(&self.root, &self.config.0, &self.config.1)
}
@@ -162,11 +146,6 @@ impl PathFilter {
&self.root
}
/// Whether content roots outside the project root are indexed with it.
pub(crate) fn has_extra_roots(&self) -> bool {
!self.extra.is_empty()
}
/// The project root, then the extra content roots.
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
std::iter::once(self.root.as_path())
@@ -248,30 +227,6 @@ impl PathFilter {
dir.is_some_and(|d| gitignore.matched_path_or_any_parents(d, true).is_ignore())
}
/// All directories at or below `dir` that the index would walk, in walk order.
pub(crate) fn dirs(&self, dir: &Path) -> Vec<PathBuf> {
let Some((root, _, _)) = self.base_of(dir) else { return Vec::new() };
let root = root.to_path_buf();
let excludes = self.excludes.clone();
let walk_root = root.clone();
let walk_excludes = excludes.clone();
let walker = WalkBuilder::new(dir)
.hidden(false)
.parents(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(ide_core::ignored::inside_git_repo(dir))
.follow_links(false)
.filter_entry(move |e| {
let name = e.file_name().to_string_lossy();
!Self::skip_name(&name) && !glob_excluded(&walk_excludes, &walk_root, e.path())
})
.build();
walker.flatten().filter(|e| e.file_type().is_some_and(|t| t.is_dir())).map(|e| e.into_path()).collect()
}
/// All indexable files at or below `dir`, walked in parallel.
/// Stops early (returning what was found so far) once `cancel` is set.
pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec<Found> {
+17 -9
View File
@@ -199,11 +199,12 @@ impl ProjectIndex {
return None;
}
Some(
data.symbols_of(file)
file.symbols
.iter()
.map(|s| ide_language::SymbolTag {
name: s.name.to_string(),
kind: s.kind,
container: s.container.map(ToString::to_string),
container: s.container.as_ref().map(ToString::to_string),
range: s.range,
})
.collect(),
@@ -230,7 +231,7 @@ impl ProjectIndex {
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
let recent = self.shared.recent.lock().clone();
let d = self.shared.data.read();
matching::find_files(&d, &matching::Recents::new(&recent), query, limit)
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// All indexed files with this exact basename, for resolving printed source locations.
@@ -273,7 +274,7 @@ impl ProjectIndex {
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
let recent = self.shared.recent.lock().clone();
let d = self.shared.data.read();
matching::find_symbols(&d, &matching::Recents::new(&recent), query, filter, limit)
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
}
/// Scoped symbol search with cooperative interruption, including lock waits.
@@ -302,7 +303,15 @@ impl ProjectIndex {
break data;
}
};
matching::find_symbols_until(&data, &matching::Recents::new(&recent), query, filter, limit, include, stopped)
matching::find_symbols_until(
&data,
&matching::Recents { paths: &recent },
query,
filter,
limit,
include,
stopped,
)
}
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
@@ -514,8 +523,7 @@ pub mod bench_support {
})
.collect();
let record = FileRecord { symbols, ..Default::default() };
let (entry, symbols) = FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record);
data.insert(entry, symbols);
data.insert(FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record));
}
let recent = data.paths().take(MAX_RECENT).cloned().collect();
Self { data, recent }
@@ -530,7 +538,7 @@ pub mod bench_support {
}
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_files(&self.data, &matching::Recents::new(&self.recent), query, limit)
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
}
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
@@ -538,7 +546,7 @@ pub mod bench_support {
}
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
matching::find_symbols(&self.data, &matching::Recents::new(&self.recent), query, filter, limit)
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
}
}
}
+108 -160
View File
@@ -4,19 +4,14 @@
//! (bit-mask pre-filter, nucleo score, exact/prefix boosts) and keeps the best
//! few; the survivors then get match indices, CamelHump and recency boosts and
//! are re-sorted.
//!
//! The scan streams the compact tables in `data` (`HotFiles`/`HotDirs`/`SymTable`):
//! only the hot fields per candidate and strings from contiguous arenas, with the
//! thread-local matcher and one UTF-32 buffer per scan rather than per candidate.
use crate::data::{DirRow, FileEntry, FileRow, IndexData, SymTable, char_mask};
use crate::data::{FileEntry, IndexData, char_mask};
use crate::{FileMatch, SymbolFilter, SymbolMatch};
use ide_core::SymbolKind;
use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
use rayon::prelude::*;
use std::cell::RefCell;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const TIER_NAME: u32 = 1 << 20;
@@ -59,11 +54,6 @@ impl Needle {
m.fuzzy_match(Utf32Str::new(hay, buf), self.utf32.slice(..)).map(u32::from)
}
/// `hay` is pure ASCII: skip `Utf32Str::new`'s check and UTF-32 conversion.
fn score_ascii(&self, m: &mut Matcher, hay: &str) -> Option<u32> {
m.fuzzy_match(Utf32Str::Ascii(hay.as_bytes()), self.utf32.slice(..)).map(u32::from)
}
fn indices(&self, m: &mut Matcher, hay: &str, buf: &mut Vec<char>, out: &mut Vec<u32>) -> Option<u32> {
out.clear();
let s = m.fuzzy_indices(Utf32Str::new(hay, buf), self.utf32.slice(..), out).map(u32::from);
@@ -115,9 +105,6 @@ struct Cand {
slot: u32,
sym: u32,
score: u32,
/// Candidate name/rel length: the first tie-break key, cached so ranking a pool
/// does not chase it through the tables.
len: u32,
}
fn keep_best(mut v: Vec<Cand>, k: usize, tiebreak: &impl Fn(&Cand, &Cand) -> Ordering) -> Vec<Cand> {
@@ -132,7 +119,7 @@ fn scan(
data: &IndexData,
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Vec<Cand> {
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
}
@@ -142,22 +129,18 @@ fn scan_until(
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
stopped: &(impl Fn() -> bool + Sync),
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Option<Vec<Cand>> {
// One row per slot, so this is exactly the old scan over `files`: same item
// order and count, and free slots are skipped exactly like `None` was, keeping
// the pruning boundaries (and therefore the candidate pool) unchanged.
data.hot
.rows
data.files
.par_iter()
.enumerate()
.with_min_len(256)
.try_fold(Vec::new, |mut acc, (slot, row)| {
.try_fold(Vec::new, |mut acc, (slot, e)| {
if stopped() {
return None;
}
if row.live {
with_matcher(|m| per_file(slot as u32, row, m, &mut acc));
if let Some(e) = e {
per_file(slot as u32, e, &mut acc);
if acc.len() >= pool * 2 {
acc = keep_best(acc, pool, tiebreak);
}
@@ -172,22 +155,15 @@ fn scan_until(
pub(crate) struct Recents<'a> {
pub paths: &'a [PathBuf],
/// Position of each path, so ranking a survivor does not scan the list.
/// Duplicates keep their first position, like `position`.
ranks: HashMap<&'a Path, usize>,
}
impl<'a> Recents<'a> {
pub(crate) fn new(paths: &'a [PathBuf]) -> Self {
let mut ranks = HashMap::with_capacity(paths.len());
for (i, p) in paths.iter().enumerate() {
ranks.entry(p.as_path()).or_insert(i);
}
Self { paths, ranks }
impl Recents<'_> {
fn rank(&self, path: &std::path::Path) -> Option<usize> {
self.paths.iter().position(|p| p == path)
}
fn bonus(&self, path: &Path) -> u32 {
self.ranks.get(path).map_or(0, |&r| RECENT_MAX.saturating_sub(r as u32 * 12))
fn bonus(&self, path: &std::path::Path) -> u32 {
self.rank(path).map_or(0, |r| RECENT_MAX.saturating_sub(r as u32 * 12))
}
}
@@ -233,31 +209,29 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit
Some((dir, name)) => FileQuery::Path { dir: Needle::new(dir), name: Needle::new(name) },
None => FileQuery::Name(Needle::new(&q)),
};
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
let tiebreak = |a: &Cand, b: &Cand| {
let (ea, eb) = (file(data, a.slot), file(data, b.slot));
ea.rel.len().cmp(&eb.rel.len()).then_with(|| ea.rel.cmp(&eb.rel))
};
let pool = limit * 4 + 64;
let mut cands = scan(data, pool, &tiebreak, |slot, row, m, acc| {
let mut cands = scan(data, pool, &tiebreak, |slot, e, acc| {
let mut buf = Vec::new();
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
acc.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
acc.push(Cand { slot, sym: u32::MAX, score });
}
});
// Recent files always compete for the final ranking.
with_matcher(|m| {
let mut buf = Vec::new();
for p in recents.paths {
if data.get(p).is_none() {
continue;
}
for p in recents.paths {
if let Some(e) = data.get(p) {
let Some(slot) = data.slot(p) else { continue };
if cands.iter().any(|c| c.slot == slot) {
continue;
}
let Some(row) = data.hot.row_of_slot(slot) else { continue };
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
cands.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
if !cands.iter().any(|c| c.slot == slot) {
let mut buf = Vec::new();
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
cands.push(Cand { slot, sym: u32::MAX, score });
}
}
}
});
}
let mut out: Vec<(u32, &FileEntry, Vec<u32>)> = with_matcher(|m| {
let mut buf = Vec::new();
cands
@@ -299,46 +273,30 @@ pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec<File
}
let by_path = q.contains('/');
let needle = Needle::new(q.trim_end_matches('/'));
let text = data.dirs_hot.text();
// (score, rel without the trailing `/`, char offset of the name when it matched by name)
let (mut best, _): (Vec<_>, Vec<char>) = data
.dirs_hot
.rows
let score = |m: &mut Matcher, buf: &mut Vec<char>, rel: &str| -> Option<(u32, Option<u32>)> {
let trimmed = &rel[..rel.len() - 1];
let start = trimmed.rfind('/').map_or(0, |i| i + 1);
let name = &trimmed[start..];
if !by_path
&& needle.mask & char_mask(name) == needle.mask
&& let Some(s) = needle.score(m, name, buf)
{
return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32)));
}
if needle.mask & char_mask(trimmed) != needle.mask {
return None;
}
needle.score(m, trimmed, buf).map(|s| (s, None))
};
let mut best: Vec<(u32, &str, &Path, Option<u32>)> = data
.dirs
.par_iter()
.fold(
|| (Vec::new(), Vec::new()),
|(mut found, mut buf), row: &DirRow| {
let rel = row.rel(text);
let trimmed = &rel[..rel.len() - 1];
let name = &trimmed[row.name_start as usize..];
with_matcher(|m| {
let hit = if !by_path
&& needle.mask & row.name_mask == needle.mask
&& let Some(s) = needle.score(m, name, &mut buf)
{
Some((
TIER_NAME + s * 4 + needle.name_boost(name),
Some(trimmed[..row.name_start as usize].chars().count() as u32),
))
} else if needle.mask & row.rel_mask != needle.mask {
None
} else {
needle.score(m, trimmed, &mut buf).map(|s| (s, None))
};
if let Some((s, at)) = hit {
found.push((s, rel, row.path.as_path(), at));
}
});
(found, buf)
},
)
.reduce(
|| (Vec::new(), Vec::new()),
|mut a, b| {
a.0.extend(b.0);
a
},
);
.filter_map(|(rel, (path, _))| {
let mut buf = Vec::new();
with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at))
})
.collect();
let order = |a: &(u32, &str, &Path, Option<u32>), b: &(u32, &str, &Path, Option<u32>)| {
b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1))
};
@@ -380,39 +338,33 @@ fn file(data: &IndexData, slot: u32) -> &FileEntry {
data.files[slot as usize].as_ref().expect("candidate slots are live")
}
fn base_file_score(m: &mut Matcher, q: &FileQuery, row: &FileRow, text: &str, buf: &mut Vec<char>) -> Option<u32> {
// Strings are sliced only once their mask passes, so a rejected row costs two masks.
fn base_file_score(m: &mut Matcher, q: &FileQuery, e: &FileEntry, buf: &mut Vec<char>) -> Option<u32> {
match q {
FileQuery::Name(n) => {
if n.mask & row.name_mask == n.mask {
let name = row.name(text);
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) };
if let Some(s) = s {
return Some(TIER_NAME + s * 4 + n.name_boost(name));
}
if n.mask & e.name_mask == n.mask
&& let Some(s) = n.score(m, e.name(), buf)
{
return Some(TIER_NAME + s * 4 + n.name_boost(e.name()));
}
if n.mask & row.rel_mask == n.mask {
let rel = row.rel(text);
return if row.ascii { n.score_ascii(m, rel) } else { n.score(m, rel, buf) };
if n.mask & e.rel_mask == n.mask {
return n.score(m, &e.rel, buf);
}
None
}
FileQuery::Path { dir, name: n } => {
let name_score = if n.is_empty() {
FileQuery::Path { dir, name } => {
let name_score = if name.is_empty() {
0
} else {
if n.mask & row.name_mask != n.mask {
if name.mask & e.name_mask != name.mask {
return None;
}
let name = row.name(text);
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) }?;
s * 4 + n.name_boost(name)
name.score(m, e.name(), buf)? * 4 + name.name_boost(e.name())
};
if dir.mask & row.dir_mask != dir.mask {
let dir_str = e.dir();
if dir.mask & char_mask(dir_str) != dir.mask {
return None;
}
let dir_str = row.dir(text);
let dir_score = if row.ascii { dir.score_ascii(m, dir_str) } else { dir.score(m, dir_str, buf) }?;
let dir_score = dir.score(m, dir_str, buf)?;
Some(TIER_NAME + name_score + dir_score)
}
}
@@ -444,21 +396,22 @@ fn symbol_score(
m: &mut Matcher,
name: &Needle,
container: Option<&Needle>,
syms: &SymTable,
i: u32,
s: &crate::data::Sym,
buf: &mut Vec<char>,
) -> Option<u32> {
let symbol = syms.name(i);
let mut score = if name.is_empty() { 0 } else { name.score(m, symbol, buf)? * 4 };
score += name.name_boost(symbol);
if name.mask & s.mask != name.mask {
return None;
}
let mut score = if name.is_empty() { 0 } else { name.score(m, &s.name, buf)? * 4 };
score += name.name_boost(&s.name);
if let Some(cq) = container {
let c = syms.container(i)?;
let c = s.container.as_deref()?;
if cq.mask & char_mask(c) != cq.mask {
return None;
}
score += cq.score(m, c, buf)? + cq.name_boost(c) / 4;
}
if syms.kind(i).is_type_like() {
if s.kind.is_type_like() {
score += TYPE_LIKE;
}
Some(score)
@@ -502,35 +455,30 @@ pub(crate) fn find_symbols_until(
let (container_q, name_q) = split_symbol_query(&q);
let name = Needle::new(name_q);
let container = container_q.map(Needle::new);
let syms = &data.syms;
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
let sym = |c: &Cand| &file(data, c.slot).symbols[c.sym as usize];
let tiebreak = |a: &Cand, b: &Cand| {
let (sa, sb) = (sym(a), sym(b));
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
};
let pool = limit * 4 + 64;
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, row, m, acc| {
// Whole-file pre-filter: when the union of the file's symbol masks
// already misses a query character, no symbol can match.
if row.sym_len == 0 || name.mask & row.sym_mask != name.mask {
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
if e.symbols.is_empty() || !include(&e.path) {
return;
}
let e = file(data, slot);
if !include(&e.path) {
return;
}
let mut buf = Vec::new();
for (k, head) in syms.heads(row.sym_start, row.sym_len).iter().enumerate() {
if stopped() {
break;
with_matcher(|m| {
let mut buf = Vec::new();
for (i, s) in e.symbols.iter().enumerate() {
if stopped() {
break;
}
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
continue;
}
if let Some(score) = symbol_score(m, &name, container.as_ref(), s, &mut buf) {
acc.push(Cand { slot, sym: i as u32, score });
}
}
if filter == SymbolFilter::Types && !syms.kind(row.sym_start + k as u32).is_type_like() {
continue;
}
if name.mask & head.mask != name.mask {
continue;
}
let i = row.sym_start + k as u32;
if let Some(score) = symbol_score(m, &name, container.as_ref(), syms, i, &mut buf) {
acc.push(Cand { slot, sym: i, score, len: head.name.len });
}
}
});
})?;
if stopped() {
return None;
@@ -541,32 +489,33 @@ pub(crate) fn find_symbols_until(
.into_iter()
.take_while(|_| !stopped())
.filter_map(|c| {
let symbol = syms.name(c.sym);
let s = sym(&c);
let e = file(data, c.slot);
let mut idx = Vec::new();
if !name.is_empty() {
name.indices(m, symbol, &mut buf, &mut idx)?;
name.indices(m, &s.name, &mut buf, &mut idx)?;
}
let bonus = hump_bonus(symbol, &idx) + recents.bonus(&e.path) / 4;
let bonus = hump_bonus(&s.name, &idx) + recents.bonus(&e.path) / 4;
Some((c.score + bonus, c, idx))
})
.collect()
});
out.sort_by(|a, b| {
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| syms.range(a.1.sym).cmp(&syms.range(b.1.sym)))
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
});
out.truncate(limit);
let results = out
.into_iter()
.map(|(score, c, indices)| {
let e = file(data, c.slot);
let s = &e.symbols[c.sym as usize];
SymbolMatch {
name: syms.name(c.sym).to_string(),
kind: syms.kind(c.sym),
container: syms.container(c.sym).map(str::to_owned),
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
path: e.path.clone().into(),
rel_path: e.rel.to_string(),
range: syms.range(c.sym),
range: s.range,
score,
indices,
}
@@ -592,25 +541,24 @@ pub(crate) fn symbols_named_within(
) -> Vec<SymbolMatch> {
let mask = char_mask(name);
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
let syms = &data.syms;
let mut out: Vec<SymbolMatch> = data
.files
.iter()
.flatten()
.filter(|e| within(e))
.flat_map(|e| {
(e.sym_start..e.sym_start + e.sym_len)
.filter(|&i| syms.mask(i) == mask && syms.name(i) == name && kind.is_none_or(|k| syms.kind(i) == k))
.map(move |i| SymbolMatch {
name: syms.name(i).to_string(),
kind: syms.kind(i),
container: syms.container(i).map(str::to_owned),
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
move |s| SymbolMatch {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
path: e.path.clone().into(),
rel_path: e.rel.to_string(),
range: syms.range(i),
range: s.range,
score: 0,
indices: Vec::new(),
})
},
)
})
.collect();
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path).then_with(|| a.range.cmp(&b.range)));
+39 -568
View File
@@ -1,408 +1,75 @@
//! Persistence of file records in redb, so a restart only re-parses changed files.
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{PathFilter, mtime_of};
use ide_core::{Range, SymbolKind};
use crate::data::FileRecord;
use parking_lot::RwLock;
use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition, TableError};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
/// Bump when [`FileRecord`] or its encoding changes.
const SCHEMA: &str = "openidea-index-3";
/// The real Git directory for a checkout/worktree (`root/.git` may be a file
/// containing `gitdir: ...`).
fn git_dir(root: &Path) -> Option<std::path::PathBuf> {
let dot_git = root.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
let text = std::fs::read_to_string(&dot_git).ok()?;
let target = text.strip_prefix("gitdir:")?.trim();
let path = Path::new(target);
Some(if path.is_absolute() { path.to_owned() } else { root.join(path) })
}
/// External ignore/config inputs, including candidates that do not exist yet.
/// These are outside the directory manifest (`.git` is skipped), so the full
/// path set must match at warm start.
pub(crate) fn external_ignore_candidates(root: &Path) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
if let Some(git) = git_dir(root) {
out.insert(git.join("config"));
out.insert(git.join("info/exclude"));
}
out.insert(root.join(".git/config"));
out.insert(root.join(".git/info/exclude"));
if let Some(path) = ignore::gitignore::gitconfig_excludes_path() {
out.insert(path);
}
out.insert(std::path::PathBuf::from("/etc/gitconfig"));
if let Some(path) = std::env::var_os("GIT_CONFIG_GLOBAL") {
out.insert(path.into());
}
if let Some(home) = std::env::var_os("HOME") {
let home = std::path::PathBuf::from(home);
out.insert(home.join(".gitconfig"));
out.insert(
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| home.join(".config"))
.join("git/config"),
);
}
out.into_iter().collect()
}
/// Existing per-directory ignore files. Adding/removing one changes the
/// directory mtime, which the manifest already validates before this list is used.
fn per_dir_ignore_candidates(root: &Path, dirs: &[String]) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
for rel in dirs {
let dir = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
for name in [".gitignore", ".ignore"] {
let path = dir.join(name);
if path.is_file() {
out.insert(path);
}
}
}
out.into_iter().collect()
}
/// One file's fixed-size columns in the start-up snapshot.
#[derive(Clone, Copy, Serialize, Deserialize, Default)]
struct FileMeta {
mtime: u64,
size: u64,
hash: [u8; 32],
ignored: bool,
/// End of this file's symbols in [`Snapshot::symbols`].
sym_end: u32,
}
/// One symbol's fixed-size columns; names and containers share [`Snapshot::text`].
#[derive(Clone, Copy, Serialize, Deserialize)]
struct FlatSymbol {
name_start: u32,
name_len: u32,
container_start: u32,
container_len: u32,
kind: SymbolKind,
range: Range,
}
/// A columnar copy of the index for fast start-up: a handful of contiguous
/// arrays and two string arenas instead of one postcard record per redb key.
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct Snapshot {
rels: String,
rel_ends: Vec<u32>,
meta: Vec<FileMeta>,
symbols: Vec<FlatSymbol>,
text: String,
dirs: String,
dir_ends: Vec<u32>,
dir_mtimes: Vec<u64>,
dir_counts: Vec<u32>,
filter: String,
stamp: String,
ignore_paths: String,
ignore_ends: Vec<u32>,
ignore_mtimes: Vec<u64>,
ignore_sizes: Vec<u64>,
ignore_present: Vec<bool>,
ignore_external: Vec<bool>,
}
impl Snapshot {
pub(crate) fn len(&self) -> usize {
self.meta.len()
}
pub(crate) fn filter(&self) -> &str {
&self.filter
}
pub(crate) fn dirs_len(&self) -> usize {
self.dir_mtimes.len()
}
pub(crate) fn dir(&self, dir: usize) -> &str {
let start = if dir == 0 { 0 } else { self.dir_ends[dir - 1] as usize };
&self.dirs[start..self.dir_ends[dir] as usize]
}
pub(crate) fn dir_mtime(&self, dir: usize) -> u64 {
self.dir_mtimes[dir]
}
pub(crate) fn dir_count(&self, dir: usize) -> u32 {
self.dir_counts[dir]
}
pub(crate) fn ignore_len(&self) -> usize {
self.ignore_mtimes.len()
}
pub(crate) fn ignore_path(&self, i: usize) -> &str {
let start = if i == 0 { 0 } else { self.ignore_ends[i - 1] as usize };
&self.ignore_paths[start..self.ignore_ends[i] as usize]
}
pub(crate) fn ignore_mtime(&self, i: usize) -> u64 {
self.ignore_mtimes[i]
}
pub(crate) fn ignore_size(&self, i: usize) -> u64 {
self.ignore_sizes[i]
}
pub(crate) fn ignore_present(&self, i: usize) -> bool {
self.ignore_present[i]
}
pub(crate) fn ignore_external(&self, i: usize) -> bool {
self.ignore_external[i]
}
pub(crate) fn mtime(&self, file: usize) -> u64 {
self.meta[file].mtime
}
pub(crate) fn size(&self, file: usize) -> u64 {
self.meta[file].size
}
/// Rejects a corrupt snapshot before any offsets are used for slicing.
fn valid(&self) -> bool {
let max = u32::MAX as usize;
if self.rels.len() > max || self.text.len() > max || self.dirs.len() > max || self.ignore_paths.len() > max {
return false;
}
if self.rel_ends.len() != self.meta.len() {
return false;
}
let boundary = |text: &str, end: u32| end as usize <= text.len() && text.is_char_boundary(end as usize);
let mut rel_end = 0;
for end in &self.rel_ends {
if *end < rel_end || !boundary(&self.rels, *end) {
return false;
}
rel_end = *end;
}
if rel_end as usize != self.rels.len() {
return false;
}
let mut start = 0;
for end in &self.rel_ends {
let rel = &self.rels[start..*end as usize];
if !crate::data::safe_rel(rel) {
return false;
}
start = *end as usize;
}
let mut sym_end = 0;
for meta in &self.meta {
if meta.sym_end < sym_end || meta.sym_end as usize > self.symbols.len() {
return false;
}
sym_end = meta.sym_end;
}
if sym_end as usize != self.symbols.len() {
return false;
}
if !self.symbols.iter().all(|s| {
let Some(name_end) = (s.name_start as usize).checked_add(s.name_len as usize) else {
return false;
};
let Some(container_end) = (s.container_start as usize).checked_add(s.container_len as usize) else {
return false;
};
name_end <= self.text.len()
&& self.text.is_char_boundary(s.name_start as usize)
&& self.text.is_char_boundary(name_end)
&& (s.container_start == u32::MAX
|| (s.container_start as usize <= self.text.len()
&& container_end <= self.text.len()
&& self.text.is_char_boundary(s.container_start as usize)
&& self.text.is_char_boundary(container_end)))
}) {
return false;
}
if self.dir_ends.len() != self.dir_mtimes.len() || self.dir_counts.len() != self.dir_mtimes.len() {
return false;
}
if self.dir_ends.first() != Some(&0) {
return false;
}
let mut dir_end = 0;
for end in &self.dir_ends {
if *end < dir_end || !boundary(&self.dirs, *end) {
return false;
}
dir_end = *end;
}
if dir_end as usize != self.dirs.len() {
return false;
}
let mut start = 0;
for end in &self.dir_ends {
let dir = &self.dirs[start..*end as usize];
if !dir.is_empty() && !crate::data::safe_rel(dir) {
return false;
}
start = *end as usize;
}
if self.ignore_ends.len() != self.ignore_mtimes.len()
|| self.ignore_sizes.len() != self.ignore_mtimes.len()
|| self.ignore_present.len() != self.ignore_mtimes.len()
|| self.ignore_external.len() != self.ignore_mtimes.len()
{
return false;
}
let mut ignore_end = 0;
for end in &self.ignore_ends {
if *end < ignore_end || !boundary(&self.ignore_paths, *end) {
return false;
}
ignore_end = *end;
}
if ignore_end as usize != self.ignore_paths.len() {
return false;
}
let mut start = 0;
for end in &self.ignore_ends {
if start == *end as usize {
return false;
}
start = *end as usize;
}
true
}
pub(crate) fn rel(&self, file: usize) -> &str {
let start = if file == 0 { 0 } else { self.rel_ends[file - 1] as usize };
&self.rels[start..self.rel_ends[file] as usize]
}
pub(crate) fn entry(&self, file: usize, root: &Path) -> (FileEntry, Vec<StoredSymbol>) {
let meta = self.meta[file];
let sym_start = if file == 0 { 0 } else { self.meta[file - 1].sym_end as usize };
let symbols = (sym_start..meta.sym_end as usize)
.map(|i| {
let s = self.symbols[i];
let name_start = s.name_start as usize;
let name_end = name_start + s.name_len as usize;
let name = &self.text[name_start..name_end];
let container = (s.container_start != u32::MAX).then(|| {
let start = s.container_start as usize;
&self.text[start..start + s.container_len as usize]
});
StoredSymbol {
name: name.to_owned(),
kind: s.kind,
container: container.map(str::to_owned),
range: s.range,
}
})
.collect();
let rel = self.rel(file);
FileEntry::new(
root.join(rel),
rel.to_owned(),
FileRecord { mtime: meta.mtime, size: meta.size, hash: meta.hash, symbols, ignored: meta.ignored },
)
}
}
type Result<T> = std::result::Result<T, String>;
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
fn fit(n: usize) -> Result<u32> {
u32::try_from(n).map_err(|_| "index snapshot exceeds the 4 GiB span limit".to_owned())
}
pub(crate) struct Store {
/// Opened lazily: a valid warm-start snapshot serves start-up without
/// touching redb. Write-locked only for [`Store::compact`].
db: RwLock<Option<Database>>,
/// Columnar warm-start snapshot, kept beside the database.
snapshot: std::path::PathBuf,
/// Recent-file list, readable without opening redb.
recent_file: std::path::PathBuf,
stamp: String,
/// Write-locked only for [`Store::compact`].
db: RwLock<Database>,
}
impl Store {
/// Opens (or creates) the cache directory. The redb database itself is opened
/// lazily so a valid columnar snapshot can serve start-up without touching it.
/// Returns `None` only when the cache directory cannot be created; the index
/// then runs in memory only.
/// Opens (or creates) `cache_dir/index.redb`. Records written by another
/// schema / extractor version or for another root are discarded. Returns
/// `None` when the database cannot be used (e.g. locked by another window);
/// the index then runs in memory only.
pub(crate) fn open(cache_dir: &Path, root: &Path, fingerprint: &str) -> Option<Self> {
if let Err(e) = std::fs::create_dir_all(cache_dir) {
tracing::warn!("index cache {}: {e}", cache_dir.display());
return None;
}
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
Some(Self {
db: RwLock::new(None),
snapshot: cache_dir.join("index.snapshot"),
recent_file: cache_dir.join("recent.snapshot"),
stamp,
})
}
fn open_db(&self) -> Result<Database> {
let file = self.snapshot.with_file_name("index.redb");
let file = cache_dir.join("index.redb");
let db = match Database::create(&file) {
Ok(db) => db,
Err(redb::DatabaseError::DatabaseAlreadyOpen) => {
return Err(format!("{} is in use", file.display()));
tracing::warn!("index cache {} is in use; indexing in memory only", file.display());
return None;
}
Err(e) => {
tracing::warn!("index cache {} unreadable ({e}); rebuilding", file.display());
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_file(&self.snapshot);
Database::create(&file).map_err(err)?
Database::create(&file).map_err(|e| tracing::warn!("index cache: {e}")).ok()?
}
};
let valid = {
let txn = db.begin_read().map_err(err)?;
match txn.open_table(META) {
Ok(table) => table.get("stamp").map_err(err)?.is_some_and(|v| v.value() == self.stamp.as_bytes()),
Err(TableError::TableDoesNotExist(_)) => false,
Err(e) => return Err(err(e)),
let store = Self { db: RwLock::new(db) };
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
if let Err(e) = store.reset(&stamp) {
tracing::warn!("index cache reset failed: {e}");
return None;
}
}
}
Some(store)
}
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
let txn = self.db.read().begin_read().map_err(err)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(err(e)),
};
if !valid {
let _ = std::fs::remove_file(&self.snapshot);
let _ = std::fs::remove_file(&self.recent_file);
Self::reset_db(&db, &self.stamp)?;
}
Ok(db)
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
}
fn ensure_db(&self) -> Result<()> {
if self.db.read().is_some() {
return Ok(());
}
let mut slot = self.db.write();
if slot.is_none() {
*slot = Some(self.open_db()?);
}
Ok(())
}
fn reset_db(db: &Database, stamp: &str) -> Result<()> {
let txn = db.begin_write().map_err(err)?;
fn reset(&self, stamp: &str) -> Result<()> {
let txn = self.db.read().begin_write().map_err(err)?;
match txn.delete_table(FILES) {
Ok(_) | Err(TableError::TableDoesNotExist(_)) => {}
Err(e) => return Err(err(e)),
@@ -415,31 +82,16 @@ impl Store {
txn.commit().map_err(err)
}
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(err(e)),
};
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
}
/// All persisted records; undecodable entries are skipped (and re-indexed).
pub(crate) fn load(&self) -> Result<Vec<(String, FileRecord)>> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
let txn = self.db.read().begin_read().map_err(err)?;
let table = match txn.open_table(FILES) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
Err(e) => return Err(err(e)),
};
let iter = table.iter().map_err(err)?;
let mut out = Vec::with_capacity(iter.size_hint().0);
for item in iter {
let mut out = Vec::new();
for item in table.iter().map_err(err)? {
let (k, v) = item.map_err(err)?;
if let Ok(rec) = postcard::from_bytes::<FileRecord>(v.value()) {
out.push((k.value().to_owned(), rec));
@@ -448,115 +100,10 @@ impl Store {
Ok(out)
}
/// Writes the current index as one columnar snapshot for fast next start-up.
/// Only records persisted below `root` are included; content roots outside it
/// are rescanned, as they are for the per-file records.
pub(crate) fn save_snapshot(&self, data: &IndexData, filter: &PathFilter, not_after: SystemTime) -> Result<()> {
let guard = not_after.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64);
let root = filter.root();
let mut snap = Snapshot { filter: filter.stamp(), stamp: self.stamp.clone(), ..Default::default() };
let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
dirs.insert(String::new());
for dir in filter.dirs(root) {
if let Ok(rel) = dir.strip_prefix(root) {
dirs.insert(rel.to_string_lossy().replace('\\', "/").trim_end_matches('/').to_owned());
}
}
for e in data.files.iter().flatten().filter(|e| e.path.starts_with(root)) {
dirs.insert(e.dir().trim_end_matches('/').to_owned());
snap.rels.push_str(&e.rel);
snap.rel_ends.push(fit(snap.rels.len())?);
for s in data.symbols_of(e) {
let name_start = fit(snap.text.len())?;
snap.text.push_str(s.name);
let (container_start, container_len) = match s.container {
Some(c) => {
let start = fit(snap.text.len())?;
snap.text.push_str(c);
(start, fit(c.len())?)
}
None => (u32::MAX, 0),
};
snap.symbols.push(FlatSymbol {
name_start,
name_len: fit(s.name.len())?,
container_start,
container_len,
kind: s.kind,
range: s.range,
});
}
snap.meta.push(FileMeta {
mtime: e.mtime,
size: e.size,
hash: e.hash,
ignored: e.ignored,
sym_end: fit(snap.symbols.len())?,
});
}
let dir_list: Vec<String> = dirs.into_iter().collect();
for rel in &dir_list {
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
let mtime = std::fs::symlink_metadata(&path).ok().map(|m| mtime_of(&m)).unwrap_or(0);
if mtime > guard {
let _ = std::fs::remove_file(&self.snapshot);
return Ok(());
}
let count = if rel.is_empty() {
0
} else {
let key = format!("{rel}/");
data.dirs.get(key.as_str()).map_or(0, |d| d.1)
};
snap.dirs.push_str(rel);
snap.dir_ends.push(fit(snap.dirs.len())?);
snap.dir_mtimes.push(mtime);
snap.dir_counts.push(count);
}
let external: std::collections::BTreeSet<std::path::PathBuf> =
external_ignore_candidates(root).into_iter().collect();
let mut candidates: std::collections::BTreeSet<std::path::PathBuf> =
per_dir_ignore_candidates(root, &dir_list).into_iter().collect();
candidates.extend(external.iter().cloned());
for path in candidates {
let meta = std::fs::metadata(&path).ok();
let present = meta.is_some();
let mtime = meta.as_ref().map_or(0, mtime_of);
if mtime > guard {
let _ = std::fs::remove_file(&self.snapshot);
return Ok(());
}
snap.ignore_paths.push_str(&path.to_string_lossy());
snap.ignore_ends.push(fit(snap.ignore_paths.len())?);
snap.ignore_mtimes.push(if present { mtime } else { 0 });
snap.ignore_sizes.push(if present { meta.as_ref().map_or(0, |m| m.len()) } else { 0 });
snap.ignore_present.push(present);
snap.ignore_external.push(external.contains(&path));
}
fit(snap.rels.len())?;
fit(snap.text.len())?;
fit(snap.dirs.len())?;
fit(snap.ignore_paths.len())?;
let bytes = postcard::to_allocvec(&snap).map_err(err)?;
let tmp = self.snapshot.with_extension("snapshot.tmp");
std::fs::write(&tmp, &bytes).map_err(err)?;
let _ = std::fs::remove_file(&self.snapshot);
std::fs::rename(&tmp, &self.snapshot).map_err(err)
}
/// The columnar start-up snapshot, if it was written and still decodes.
pub(crate) fn load_snapshot(&self) -> Option<Snapshot> {
let bytes = std::fs::read(&self.snapshot).ok()?;
let snapshot: Snapshot = postcard::from_bytes(&bytes).ok()?;
(snapshot.stamp == self.stamp && snapshot.valid()).then_some(snapshot)
}
/// Applies upserts and deletions in one transaction. Non-durable commits
/// are cheap (no fsync) and become durable with the next durable one.
pub(crate) fn apply(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) -> Result<()> {
self.ensure_db()?;
let db = self.db.read();
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
let mut txn = self.db.read().begin_write().map_err(err)?;
if !durable {
txn.set_durability(Durability::None).map_err(err)?;
}
@@ -573,47 +120,25 @@ impl Store {
table.remove(rel.as_str()).map_err(err)?;
}
}
// Any file change makes the columnar start-up snapshot stale.
let _ = std::fs::remove_file(&self.snapshot);
txn.commit().map_err(err)
}
/// Reclaims free pages in the database file; `true` if anything was compacted.
pub(crate) fn compact(&self) -> Result<bool> {
self.ensure_db()?;
let mut db = self.db.write();
db.as_mut().expect("ensured").compact().map_err(err)
self.db.write().compact().map_err(err)
}
pub(crate) fn load_recent(&self) -> Vec<String> {
if let Some(recent) = self.load_recent_file() {
return recent;
}
self.meta("recent").ok().flatten().and_then(|v| postcard::from_bytes(&v).ok()).unwrap_or_default()
}
/// Recent files without opening redb (the warm-start path).
pub(crate) fn load_recent_file(&self) -> Option<Vec<String>> {
let bytes = std::fs::read(&self.recent_file).ok()?;
postcard::from_bytes(&bytes).ok()
}
pub(crate) fn save_recent(&self, recent: &[String]) -> Result<()> {
self.save_recent_file(recent)?;
let bytes = postcard::to_allocvec(recent).map_err(err)?;
self.set_meta("recent", &bytes)
}
/// Rewrites only the recent sidecar, without opening redb.
pub(crate) fn save_recent_file(&self, recent: &[String]) -> Result<()> {
let bytes = postcard::to_allocvec(recent).map_err(err)?;
std::fs::write(&self.recent_file, &bytes).map_err(err)
}
fn set_meta(&self, key: &str, value: &[u8]) -> Result<()> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
let txn = self.db.read().begin_write().map_err(err)?;
txn.open_table(META).map_err(err)?.insert(key, value).map_err(err)?;
txn.commit().map_err(err)
}
@@ -658,58 +183,4 @@ mod tests {
let s = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
assert!(s.load().unwrap().is_empty());
}
#[test]
fn snapshot_roundtrips_and_is_invalidated_by_changes() {
use crate::data::{FileEntry, IndexData, StoredSymbol};
use ide_core::{Range, SymbolKind};
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
let record = FileRecord {
mtime: 7,
size: 3,
symbols: vec![StoredSymbol {
name: "OrderController".into(),
kind: SymbolKind::Class,
container: Some("Order".into()),
range: Range::default(),
}],
..Default::default()
};
let mut data = IndexData::default();
let (entry, symbols) =
FileEntry::new(std::path::PathBuf::from("/p/src/Order.rs"), "src/Order.rs".into(), record);
data.insert(entry, symbols);
// Content roots outside the project root are rescanned, not snapshotted.
let (entry, symbols) =
FileEntry::new(std::path::PathBuf::from("/elsewhere/x.rs"), "x.rs".into(), FileRecord::default());
data.insert(entry, symbols);
store
.save_snapshot(&data, &PathFilter::new(Path::new("/p"), &Vec::new().into(), &[]), SystemTime::now())
.unwrap();
let snapshot = store.load_snapshot().expect("snapshot");
assert_eq!(snapshot.len(), 1);
assert_eq!(snapshot.rel(0), "src/Order.rs");
let (entry, symbols) = snapshot.entry(0, Path::new("/p"));
assert_eq!(&*entry.rel, "src/Order.rs");
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "OrderController");
assert_eq!(symbols[0].container.as_deref(), Some("Order"));
store.apply(&[], &[], false).unwrap();
assert!(store.load_snapshot().is_none(), "a file change invalidates the snapshot");
}
#[test]
fn snapshot_validation_rejects_bad_paths_and_split_utf8() {
let escaping =
Snapshot { rels: "../x".into(), rel_ends: vec![4], meta: vec![FileMeta::default()], ..Default::default() };
assert!(!escaping.valid());
let split =
Snapshot { rels: "é".into(), rel_ends: vec![1], meta: vec![FileMeta::default()], ..Default::default() };
assert!(!split.valid());
}
}
-82
View File
@@ -772,49 +772,6 @@ fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
#[test]
fn incremental_changes_refresh_the_warm_start_snapshot() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
let idx = f.open();
let changed = f.write("src/a.rs", "fn renamed() {}\n");
idx.update(&[changed], &[]);
assert!(idx.wait_idle(WAIT));
std::thread::sleep(Duration::from_millis(150));
assert!(f.cache.join("index.snapshot").exists(), "a watcher update must regenerate the snapshot");
}
#[test]
fn warm_start_detects_changed_ignore_rules() {
let f = Fixture::new(&[("sub/generated/a.rs", "fn a() {}\n"), (".git/HEAD", "ref: refs/heads/main\n")]);
f.write("sub/.gitignore", "");
let idx = f.open();
assert_eq!(idx.status().files, 2, "generated/a.rs plus .gitignore itself");
drop(idx);
std::thread::sleep(Duration::from_millis(20));
f.write("sub/.gitignore", "generated/\n");
let idx = f.open();
assert_eq!(idx.status().files, 1, "an updated ignore rule invalidates the warm snapshot");
}
#[test]
fn warm_start_detects_a_new_file_in_an_existing_empty_directory() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
std::fs::create_dir(f.root.join("empty")).unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1);
assert!(idx.find_dirs("empty", 10).is_empty());
drop(idx);
let idx = f.open();
assert!(idx.find_dirs("empty", 10).is_empty(), "empty folders are not searchable after warm start");
drop(idx);
f.write("empty/new.rs", "fn new_symbol() {}\n");
let idx = f.open();
assert_eq!(idx.status().files, 2);
assert_eq!(symbols(&idx, "new_symbol", SymbolFilter::All), ["new_symbol"]);
}
#[test]
fn rule_changes_refresh_only_the_affected_index_subtree() {
for (rule, subtree) in [(".git/info/exclude", ""), ("sub/.gitignore", "sub"), ("sub/.ignore", "sub")] {
@@ -877,42 +834,3 @@ fn repository_rule_changes_refresh_members_of_an_unindexed_container() {
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
}
#[test]
fn warm_start_sees_edits_deletions_and_renames_made_while_closed() {
let f = Fixture::new(&[
("src/a.rs", "fn aaaa() {}\n"),
("src/deep/b.rs", "fn bbbb() {}\n"),
("c.rs", "fn cccc() {}\n"),
]);
let idx = f.open();
assert!(idx.wait_idle(WAIT));
drop(idx);
std::thread::sleep(Duration::from_millis(200));
// Warm start of an unchanged tree keeps everything.
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert_eq!(symbols(&idx, "aaaa", SymbolFilter::All), ["aaaa"]);
drop(idx);
std::thread::sleep(Duration::from_millis(20));
// Same size, new content; a deletion; a new file two levels down; a rename.
f.write("src/a.rs", "fn zzzz() {}\n");
std::fs::remove_file(f.root.join("c.rs")).unwrap();
f.write("src/deep/deeper/n.rs", "fn nnnn() {}\n");
std::fs::rename(f.root.join("src/deep/b.rs"), f.root.join("src/deep/r.rs")).unwrap();
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "aaaa", SymbolFilter::All).is_empty(), "stale content");
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
assert!(symbols(&idx, "cccc", SymbolFilter::All).is_empty(), "deleted file");
assert_eq!(symbols(&idx, "nnnn", SymbolFilter::All), ["nnnn"]);
assert_eq!(symbols(&idx, "bbbb", SymbolFilter::All), ["bbbb"]);
assert_eq!(idx.status().files, 3);
drop(idx);
// After the cold rescan, the next warm start agrees.
std::thread::sleep(Duration::from_millis(200));
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert_eq!(idx.status().files, 3);
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
}
+46 -310
View File
@@ -1,8 +1,8 @@
//! The background indexing thread: initial load + full scan, then incremental
//! updates queued by the watcher.
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol, safe_rel};
use crate::filter::{Found, PathFilter, mtime_of};
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{Found, PathFilter};
use crate::store::Store;
use ide_core::{IdeEvent, SharedSink, TaskId};
use ide_language::{LanguageRegistry, Syntax};
@@ -12,16 +12,12 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
/// Files larger than this are listed but not parsed for symbols.
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
const CHUNK: usize = 512;
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
#[cfg(not(test))]
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
#[cfg(test)]
const SNAPSHOT_DELAY: Duration = Duration::from_millis(50);
pub(crate) const MAX_RECENT: usize = 50;
pub(crate) enum Job {
@@ -59,11 +55,6 @@ pub(crate) struct Shared {
queue: Mutex<Queue>,
wake: Condvar,
idle: Condvar,
/// Coalesced post-update snapshot refresh.
snapshot_due: Mutex<Option<Instant>>,
/// Directories whose watcher events still need reconciliation before a
/// refreshed snapshot can be trusted.
pending_dirs: Mutex<HashSet<PathBuf>>,
}
impl Shared {
@@ -88,8 +79,6 @@ impl Shared {
queue: Mutex::default(),
wake: Condvar::new(),
idle: Condvar::new(),
snapshot_due: Mutex::default(),
pending_dirs: Mutex::default(),
}
}
@@ -146,15 +135,8 @@ impl Shared {
q.busy = true;
}
if scan {
// A warm start can trust the snapshot when neither the scope nor any
// directory/file metadata changed. Rebuilding the walk's file list is
// the expensive part; validate the flat manifest first.
if !self.try_fast_start() {
let snapshot_guard = SystemTime::now();
let found = self.walk_roots(None);
self.load_persisted(&found);
self.full_scan(Some(found), snapshot_guard);
}
self.load_persisted();
self.full_scan();
} else {
self.indexing.store(false, Ordering::SeqCst);
}
@@ -165,38 +147,17 @@ impl Shared {
self.idle.notify_all();
}
loop {
let mut save_due = false;
let jobs: Vec<Job> = {
let mut q = self.queue.lock();
loop {
if self.is_cancelled() {
return;
}
if !q.jobs.is_empty() {
break;
}
let due = *self.snapshot_due.lock();
if let Some(due) = due {
if Instant::now() >= due {
save_due = true;
break;
}
self.wake.wait_until(&mut q, due);
} else {
self.wake.wait(&mut q);
}
while q.jobs.is_empty() && !self.is_cancelled() {
self.wake.wait(&mut q);
}
if save_due {
Vec::new()
} else {
q.busy = true;
q.jobs.drain(..).collect()
if self.is_cancelled() {
return;
}
q.busy = true;
q.jobs.drain(..).collect()
};
if save_due {
self.save_snapshot_now();
continue;
}
self.process(jobs);
let mut q = self.queue.lock();
q.busy = false;
@@ -206,114 +167,27 @@ impl Shared {
}
}
/// Warm start: validate the snapshot's flat directory/file manifest and hydrate
/// without walking the tree. A changed scope, symlink, directory mtime, file
/// mtime or file size falls back to the full walk.
fn try_fast_start(&self) -> bool {
let started = Instant::now();
let Some(store) = &self.store else { return false };
let load_started = Instant::now();
let Some(snapshot) = store.load_snapshot() else { return false };
let loaded = load_started.elapsed();
fn load_persisted(&self) {
let Some(store) = &self.store else { return };
let filter = self.filter();
if filter.has_extra_roots() || snapshot.filter() != filter.stamp() {
return false;
}
let root = filter.root();
for i in 0..snapshot.dirs_len() {
let rel = snapshot.dir(i);
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
let Ok(meta) = std::fs::symlink_metadata(&path) else { return false };
if !meta.is_dir() || mtime_of(&meta) != snapshot.dir_mtime(i) {
return false;
}
}
let external = crate::store::external_ignore_candidates(root);
let saved_external: Vec<&str> = (0..snapshot.ignore_len())
.filter(|&i| snapshot.ignore_external(i))
.map(|i| snapshot.ignore_path(i))
.collect();
if external.len() != saved_external.len()
|| external.iter().zip(&saved_external).any(|(path, saved)| path.to_string_lossy() != **saved)
{
return false;
}
for i in 0..snapshot.ignore_len() {
let path = Path::new(snapshot.ignore_path(i));
let meta = std::fs::metadata(path).ok();
let present = meta.is_some();
if present != snapshot.ignore_present(i) {
return false;
}
if let Some(meta) = meta
&& (mtime_of(&meta) != snapshot.ignore_mtime(i) || meta.len() != snapshot.ignore_size(i))
{
return false;
}
}
let build_started = Instant::now();
let entries: Vec<Option<(FileEntry, Vec<StoredSymbol>)>> = (0..snapshot.len())
.into_par_iter()
.map(|i| {
let path = root.join(snapshot.rel(i));
let meta = std::fs::symlink_metadata(&path).ok()?;
if !meta.is_file() || mtime_of(&meta) != snapshot.mtime(i) || meta.len() != snapshot.size(i) {
return None;
let root = filter.root().to_owned();
match store.load() {
Ok(records) => {
let entries: Vec<FileEntry> = records
.into_par_iter()
.filter(|(rel, _)| {
let path = root.join(rel);
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
for e in entries {
d.insert(e);
}
Some(snapshot.entry(i, root))
})
.collect();
let built = build_started.elapsed();
if entries.iter().any(Option::is_none) {
return false;
}
let validated = entries;
let mut entries = Vec::with_capacity(validated.len());
for entry in validated {
entries.push(entry.expect("validated above"));
}
let dirs: Vec<(Box<str>, PathBuf, u32)> = (0..snapshot.dirs_len())
.filter_map(|i| {
let rel = snapshot.dir(i);
(!rel.is_empty() && snapshot.dir_count(i) > 0).then(|| {
let key = format!("{rel}/");
(key.into_boxed_str(), root.join(rel), snapshot.dir_count(i))
})
})
.collect();
let insert_started = Instant::now();
let mut d = self.data.write();
d.load_bulk(entries, dirs);
drop(d);
tracing::info!(
"index snapshot: warm start in {:?} (load {loaded:?}, validate/build {built:?}, insert {:?})",
started.elapsed(),
insert_started.elapsed()
);
#[cfg(test)]
eprintln!(
"index snapshot: warm start load {loaded:?}, validate/build {built:?}, insert {:?}, total {:?}",
insert_started.elapsed(),
started.elapsed()
);
self.indexing.store(false, Ordering::SeqCst);
self.emit_state();
let recent = store.load_recent_file().unwrap_or_else(|| {
let recent = store.load_recent();
let _ = store.save_recent_file(&recent);
recent
});
let mut r = self.recent.lock();
for p in recent.into_iter().map(|rel| root.join(rel)) {
if !r.contains(&p) {
r.push(p);
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
}
r.truncate(MAX_RECENT);
true
}
fn load_recent(&self, store: &Store, root: &Path) {
let recent: Vec<PathBuf> = store.load_recent().into_iter().map(|rel| root.join(rel)).collect();
let mut r = self.recent.lock();
for p in recent {
@@ -324,72 +198,6 @@ impl Shared {
r.truncate(MAX_RECENT);
}
fn load_persisted(&self, found: &[Found]) {
let Some(store) = &self.store else { return };
let root = self.filter().root().to_owned();
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
let started = Instant::now();
match store.load_snapshot() {
Some(snapshot) => {
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = (0..snapshot.len())
.into_par_iter()
.filter_map(|i| {
let rel = snapshot.rel(i);
if !safe_rel(rel) {
return None;
}
let path = root.join(rel);
seen.contains(path.as_path()).then(|| snapshot.entry(i, &root))
})
.collect();
let mut d = self.data.write();
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
d.reserve(entries.len(), symbols, 0);
for (e, symbols) in entries {
d.insert(e, symbols);
}
tracing::info!(
"index snapshot: hydrated {} files, {} symbols in {:?}",
d.file_count,
d.symbol_count,
started.elapsed()
);
#[cfg(test)]
eprintln!("index snapshot: hydrated {} files in {:?}", d.file_count, started.elapsed());
}
None => match store.load() {
Ok(records) => {
// Fallback for an index that changed since the snapshot: redb
// records are decoded one file at a time and filtered by the walk.
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
.into_par_iter()
.filter(|(rel, _)| {
safe_rel(rel) && {
let path = root.join(rel);
seen.contains(path.as_path())
}
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
d.reserve(entries.len(), symbols, 0);
for (e, symbols) in entries {
d.insert(e, symbols);
}
tracing::info!(
"index cache: hydrated {} files, {} symbols in {:?}",
d.file_count,
d.symbol_count,
started.elapsed()
);
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
},
}
self.load_recent(store, &root);
}
/// A cancelled scan (project closed) still ends its progress task and the indexing state.
fn end_scan(&self, task: TaskId) {
self.indexing.store(false, Ordering::SeqCst);
@@ -404,24 +212,11 @@ impl Shared {
self.emit_state();
}
fn walk_roots(&self, dirs: Option<&[PathBuf]>) -> Vec<Found> {
let filter = self.filter();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
let mut found = Vec::new();
for root in roots {
found.extend(filter.walk(root, &self.cancelled));
}
found
fn full_scan(&self) {
self.scan(None);
}
fn full_scan(&self, found: Option<Vec<Found>>, guard: SystemTime) {
self.scan(None, found, guard);
}
fn scan(&self, dirs: Option<&[PathBuf]>, prewalked: Option<Vec<Found>>, guard: SystemTime) {
fn scan(&self, dirs: Option<&[PathBuf]>) {
let started = Instant::now();
self.indexing.store(true, Ordering::SeqCst);
self.emit_state();
@@ -436,7 +231,14 @@ impl Shared {
});
let filter = self.filter();
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
let mut found = Vec::new();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
for root in roots {
found.extend(filter.walk(root, &self.cancelled));
}
if self.is_cancelled() {
return self.end_scan(task);
}
@@ -483,17 +285,6 @@ impl Shared {
}
}
self.persist(&[], &[], true);
if let Some(store) = &self.store {
let recent: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
let d = self.data.read();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
if let Err(e) = store.save_recent_file(&recent) {
tracing::warn!("index recent snapshot: {e}");
}
}
*self.snapshot_due.lock() = None;
self.indexing.store(false, Ordering::SeqCst);
self.sink.emit(IdeEvent::Task {
id: task,
@@ -530,7 +321,8 @@ impl Shared {
match self.extract(f, prev_hash) {
Extracted::Unchanged => {
// Same content, new mtime: keep the symbols, refresh metadata.
let mut rec = self.data.read().record_of(&f.path)?;
let d = self.data.read();
let mut rec = d.get(&f.path)?.record();
rec.mtime = f.mtime;
rec.size = f.size;
rec.ignored = f.ignored;
@@ -546,46 +338,16 @@ impl Shared {
let puts: Vec<(String, FileRecord)> =
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
{
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
let entries: Vec<FileEntry> =
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
let mut d = self.data.write();
for (e, symbols) in entries {
d.insert(e, symbols);
for e in entries {
d.insert(e);
}
}
self.persist(&puts, &[], false);
}
fn schedule_snapshot(&self) {
if self.store.is_none() {
return;
}
let mut due = self.snapshot_due.lock();
if due.is_none() {
*due = Some(Instant::now() + SNAPSHOT_DELAY);
}
}
fn save_snapshot_now(&self) {
let dirs: Vec<PathBuf> = self.pending_dirs.lock().drain().collect();
if !dirs.is_empty() {
// Reconcile the directories touched since the last scan before
// recording their mtimes; otherwise a missed watcher event could be
// blessed into the manifest.
self.scan(Some(&dirs), None, SystemTime::now());
return;
}
if let Some(store) = &self.store {
let filter = self.filter();
let guard = SystemTime::now();
let d = self.data.read();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
}
*self.snapshot_due.lock() = None;
}
fn persist(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) {
if let Some(store) = &self.store
&& let Err(e) = store.apply(puts, deletes, durable)
@@ -645,17 +407,6 @@ impl Shared {
Job::Reparse(before) => reparse.push(before),
}
}
{
let mut pending = self.pending_dirs.lock();
for path in changed.iter().chain(removed.iter()) {
let dir = if path.is_dir() {
path.clone()
} else {
path.parent().map_or_else(|| path.clone(), Path::to_owned)
};
pending.insert(dir);
}
}
if !rule_dirs.is_empty() {
{
let mut filter = self.filter.write();
@@ -670,14 +421,14 @@ impl Shared {
}
}
if !rescan {
self.scan(Some(&roots), None, SystemTime::now());
self.scan(Some(&roots));
changed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
removed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
}
}
if rescan {
// The scan also picks up whatever `changed` / `removed` reported.
self.full_scan(None, SystemTime::now());
self.full_scan();
changed.clear();
removed.clear();
}
@@ -772,7 +523,6 @@ impl Shared {
if touched {
self.persist(&[], &[], true);
self.emit_state();
self.schedule_snapshot();
}
}
}
@@ -792,17 +542,3 @@ enum Extracted {
Unchanged,
New(FileRecord),
}
#[cfg(test)]
mod tests {
use crate::data::safe_rel;
#[test]
fn cache_keys_stay_below_the_root() {
assert!(safe_rel("src/main.rs"));
assert!(safe_rel("a/b/c.rs"));
for bad in ["", "/etc/passwd", "../out.rs", "a/../../b.rs", "./a.rs"] {
assert!(!safe_rel(bad), "{bad:?}");
}
}
}
+4 -10
View File
@@ -223,10 +223,6 @@ pub trait Handler: Send + Sync + 'static {
/// Called once the response to a `method` request is queued: what it sends follows the response.
fn after_response(&self, _method: &str) {}
/// Adjusts every message from the peer (responses included) before it is dispatched, e.g.
/// to repair values the peer spells more loosely than the types that read them accept.
fn normalize_incoming(&self, _message: &mut Value) {}
}
/// A [`Handler`] that ignores notifications and rejects requests.
@@ -589,10 +585,9 @@ impl Peer {
.expect("spawn jsonrpc notification thread");
}
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
// Reader: parses and dispatches; never runs handler code itself.
{
let inner = inner.clone();
let handler = handler.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let seen_message = AtomicBool::new(false);
@@ -615,7 +610,7 @@ impl Peer {
match msg {
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &*handler, &notif_tx, &body);
dispatch(&inner, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
@@ -788,16 +783,15 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
}
}
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
handler.normalize_incoming(&mut msg);
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
-26
View File
@@ -541,29 +541,3 @@ async fn output_before_the_handshake_is_reported_not_dropped() {
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn incoming_messages_are_normalized_before_dispatch() {
struct Server;
impl Handler for Server {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, params: Value) -> Result<Value, (i64, String)> {
Ok(params)
}
}
struct Client;
impl Handler for Client {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
Err((codes::METHOD_NOT_FOUND, "no".into()))
}
fn normalize_incoming(&self, message: &mut Value) {
if let Some(result) = message.get_mut("result") {
*result = json!("normalized");
}
}
}
let (client, _server) = pair(Arc::new(Client), Arc::new(Server));
let v: Value = client.request("echo", "raw", None).await.unwrap();
assert_eq!(v, json!("normalized"));
}
-28
View File
@@ -40,7 +40,6 @@ const BUILTIN: &[(&str, &str)] = &[
("groovy", include_str!("../../../languages/groovy.toml")),
("haskell", include_str!("../../../languages/haskell.toml")),
("hcl", include_str!("../../../languages/hcl.toml")),
("helm-template", include_str!("../../../languages/helm-template.toml")),
("html", include_str!("../../../languages/html.toml")),
("http", include_str!("../../../languages/http.toml")),
("ignore", include_str!("../../../languages/ignore.toml")),
@@ -80,7 +79,6 @@ const BUILTIN: &[(&str, &str)] = &[
("scala", include_str!("../../../languages/scala.toml")),
("scss", include_str!("../../../languages/scss.toml")),
("shellscript", include_str!("../../../languages/shellscript.toml")),
("slint", include_str!("../../../languages/slint.toml")),
("sql", include_str!("../../../languages/sql.toml")),
("svelte", include_str!("../../../languages/svelte.toml")),
("swift", include_str!("../../../languages/swift.toml")),
@@ -549,7 +547,6 @@ mod tests {
"Groovy",
"Haskell",
"Haxe",
"Helm Template",
"HXML",
"HTTP",
"IDL",
@@ -843,30 +840,6 @@ mod tests {
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
assert_eq!(detect("common.gotmpl"), "helm-template");
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
assert_ne!(detect("site/index.tpl"), "helm-template");
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
@@ -1073,7 +1046,6 @@ mod tests {
("icon.svg.import", "godot-resource"),
("export_presets.cfg", "godot-resource"),
("water.gdshader", "gdshader"),
("app-window.slint", "slint"),
("notes.txt", "plaintext"),
];
for (path, id) in cases {
+6 -21
View File
@@ -223,8 +223,6 @@ impl ClientHandler {
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
return;
}
let mut params = params;
crate::virtual_doc::normalize_uris(&mut params);
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
@@ -252,14 +250,7 @@ impl ClientHandler {
{
return;
}
// A late answer for an older version of the text is shown, but isn't fresh (`check_files`).
let obsolete =
params.version.is_some_and(|v| mgr.docs.lock().values().any(|d| d.path == path && d.version > v));
let shown = if obsolete {
self.server.set_obsolete_diagnostics(path.clone(), params.diagnostics)
} else {
self.server.set_diagnostics(path.clone(), params.diagnostics)
};
let shown = self.server.set_diagnostics(path.clone(), params.diagnostics);
mgr.emit_diagnostics(&self.server, path, shown, self.encoding());
}
@@ -324,13 +315,11 @@ impl ClientHandler {
}
fn apply_edit(&self, mgr: &LspManager, params: Value) -> Result<Value, (i64, String)> {
let edit: lsp::WorkspaceEdit =
params.get("edit").cloned().ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned())).and_then(
|mut e| {
crate::virtual_doc::normalize_uris(&mut e);
serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))
},
)?;
let edit: lsp::WorkspaceEdit = params
.get("edit")
.cloned()
.ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned()))
.and_then(|e| serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string())))?;
// Convert before calling the hook: it re-enters document sync.
let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit);
let applied = match mgr.apply_edit_hook() {
@@ -391,10 +380,6 @@ fn tsserver_ran_out_of_memory(text: &str) -> bool {
}
impl Handler for ClientHandler {
fn normalize_incoming(&self, message: &mut Value) {
crate::uri::encode_loose_file_uris(message);
}
fn on_notification(&self, method: &str, params: Value) {
let Some(mgr) = self.mgr.upgrade() else { return };
match method {
-119
View File
@@ -22,9 +22,6 @@ use std::time::{Duration, Instant};
/// How long Inspect Code waits for a server that is still starting (jdtls,
/// rust-analyzer on a large workspace).
const INSPECT_START_WAIT: Duration = Duration::from_secs(60);
/// Quiet period after a publish before a checked file counts as done (follow-up publishes:
/// syntax first, semantics later).
const CHECK_SETTLE: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug)]
pub struct InspectOptions {
@@ -51,29 +48,6 @@ pub trait InspectHost: Send + Sync + 'static {
fn close(&self, doc: DocId);
}
/// One language server's part in a [`DocumentCheck`].
#[derive(Clone, Debug)]
pub struct CheckedServer {
pub name: String,
/// Its state when the check ended: not `Ready`/`Busy` means it could not check.
pub status: ide_core::event::ServerStatus,
/// Why it isn't running (not installed, misconfigured, crashed), as the status bar says.
pub message: Option<String>,
/// Its diagnostics are in the result.
pub checked: bool,
/// It was still analysing (or had not published for this change) when the wait ended.
pub incomplete: bool,
/// Asking it failed.
pub error: Option<String>,
}
/// Fresh diagnostics of a document, with every language server that serves it.
#[derive(Clone, Debug, Default)]
pub struct DocumentCheck {
pub diagnostics: Vec<Diagnostic>,
pub servers: Vec<CheckedServer>,
}
/// What became of one file.
#[derive(Clone, Debug)]
pub enum FileInspection {
@@ -224,99 +198,6 @@ impl LspManager {
}
}
/// Fresh diagnostics of the open document `doc`, whose text the caller just synced
/// (`changed`: at that instant, so diagnostics published before it are stale). Tells the
/// servers the document was saved, since checks that run on save (rust-analyzer's
/// `cargo check`) don't run for a change made outside the IDE, then waits for each server
/// to be ready, to answer for this text and to finish the background work it started, up
/// to `timeout`. Every server serving the document is reported, so a caller can tell "no
/// problems" from "nothing could check".
pub async fn check_document(
&self,
doc: DocId,
changed: Option<Instant>,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
let snap = self.snapshot(doc)?;
// The text may have changed before this call synced it (the file watcher reloaded it):
// only diagnostics published after the servers got the current text are fresh.
let changed = Some(changed.map_or(snap.changed, |c| c.max(snap.changed)));
let deadline = tokio::time::Instant::now() + timeout;
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
let ready = tokio::select! {
r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
// Once the servers have the document: a save sent while one starts would not reach it.
self.did_save(doc, snap.text.as_str());
let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async {
let mut report = CheckedServer {
name: server.name.clone(),
status: server.status(),
message: server.status_message(),
checked: false,
incomplete: false,
error: None,
};
let Some(run) = run else { return Some((report, Vec::new())) };
let t = Target { server: server.clone(), run };
if !t.caps().restriction.diagnostics() {
return None;
}
let pull = t.caps().has("diagnosticProvider");
let mut since = changed;
let raw = loop {
// A pull asks for this text; a push server is waited on to publish for it.
let raw = if pull {
server.wait_idle(left(), cancel).await;
self.pull_diagnostics(&t, &snap.uri, cancel, left()).await
} else {
let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE };
wait_for_publish(server, &snap.path, since, opts, cancel).await
};
// The save may have started work (a `cargo check`) that publishes when it ends.
if raw.is_err() || pull || !server.is_busy() || left().is_zero() {
break raw;
}
if !server.wait_idle(left(), cancel).await {
break raw.map(|_| server.diagnostics_for(&snap.path));
}
// Idle: its last publish is final once quiet.
since = None;
};
report.status = server.status();
match raw {
Ok(items) => {
report.checked = true;
let fresh =
pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c));
report.incomplete = !fresh || server.is_busy();
let conv = self.conv(t.enc());
let items = items
.into_iter()
.map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name))
.collect::<Vec<_>>();
Some((report, items))
}
Err(e) => {
report.error = Some(e.message);
Some((report, Vec::new()))
}
}
});
let results = tokio::select! {
r = futures::future::join_all(per_server) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
let mut out = DocumentCheck::default();
for (report, items) in results.into_iter().flatten() {
out.servers.push(report);
out.diagnostics.extend(items);
}
Ok(out)
}
async fn pull_diagnostics(
&self,
t: &Target,
-15
View File
@@ -809,21 +809,6 @@ impl LspManager {
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
}
/// Asks jdtls to re-read library archives replaced in place (`openidea.java.refreshArchives`):
/// a SNAPSHOT installed again keeps its path, and JDT would keep showing the classes it read
/// from the old archive. Best-effort: skipped while jdtls is not already running.
pub async fn java_refresh_archives(&self, path: &Path, archives: &[PathBuf]) -> IdeResult<()> {
let java = LanguageId::new("java");
let Some(server) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER) else {
return Ok(());
};
if server.running().is_none() {
return Ok(());
}
let archives = archives.iter().map(|a| Value::String(a.to_string_lossy().into_owned())).collect();
self.jdtls_command(path, "openidea.java.refreshArchives", archives).await.map(|_| ())
}
/// Pause autobuild before resolving the launch classpath. That query takes the
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
Loaded 100 of 812 files, more files were not shown because too many files have changed in this diff. Show more