mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 20:13:56 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dff6d6504 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: |
|
||||
@@ -181,7 +181,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
|
||||
|
||||
Generated
-1
@@ -4288,7 +4288,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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>
|
||||
·
|
||||
@@ -23,6 +19,8 @@
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
·
|
||||
<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).
|
||||
|
||||
@@ -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>
|
||||
·
|
||||
<a href="https://openidea.sh/zh">文档</a>
|
||||
·
|
||||
<a href="#性能对比">性能对比</a>
|
||||
·
|
||||
<a href="#插件">插件</a>
|
||||
·
|
||||
<a href="#社区">社区</a>
|
||||
·
|
||||
<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,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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -291,7 +291,7 @@ pub enum NewTaskApprovals {
|
||||
#[derive(Clone, Debug, 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.
|
||||
@@ -300,9 +300,6 @@ pub struct McpServerSettings {
|
||||
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,
|
||||
@@ -315,8 +312,6 @@ pub struct McpServerSettings {
|
||||
|
||||
/// 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 {
|
||||
@@ -325,7 +320,6 @@ impl Default for McpServerSettings {
|
||||
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,
|
||||
@@ -679,28 +673,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 +743,6 @@ impl Default for AppearanceSettings {
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
background_image: BackgroundImageSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1159,9 +1130,6 @@ pub struct NodeSettings {
|
||||
pub run_target: String,
|
||||
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
|
||||
pub package_manager: String,
|
||||
/// Runtime for running JavaScript / TypeScript files: `node` | `bun` | `deno`; empty = from the
|
||||
/// project (`deno.json` → Deno, `bun.lock` → Bun, else Node.js).
|
||||
pub runtime: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -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"] }
|
||||
@@ -9,7 +9,6 @@
|
||||
use ide_core::process::executable_names;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
|
||||
use serde_json::Value;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -213,50 +212,6 @@ pub fn tools_root() -> Option<PathBuf> {
|
||||
TOOLS_ROOT.read().clone()
|
||||
}
|
||||
|
||||
/// The browser js-debug launches for `ty`, or `None` when the launch starts no browser.
|
||||
fn launched_browser(ty: &str) -> Option<&'static str> {
|
||||
match ty {
|
||||
"pwa-chrome" | "chrome" => Some("chrome"),
|
||||
"pwa-msedge" | "msedge" => Some("msedge"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives a browser launch that names no `userDataDir` a profile of `project`'s own in the
|
||||
/// IDE's local data (`cache/browser-profiles`), so sign-ins and site data are still there in
|
||||
/// the next session. js-debug's own default is one temporary profile for every project.
|
||||
pub(crate) fn set_browser_profile(arguments: &mut Value, project: &Path) {
|
||||
if let Some(local_data) = tools_root().and_then(|tools| tools.parent().map(Path::to_owned)) {
|
||||
set_browser_profile_in(arguments, project, &local_data);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_browser_profile_in(arguments: &mut Value, project: &Path, local_data: &Path) {
|
||||
let Some(obj) = arguments.as_object_mut() else { return };
|
||||
let browser = obj.get("type").and_then(Value::as_str).and_then(launched_browser);
|
||||
let launch = obj.get("request").and_then(Value::as_str) == Some("launch");
|
||||
let (Some(browser), true, false) = (browser, launch, obj.contains_key("userDataDir")) else { return };
|
||||
let dir = local_data.join("cache").join("browser-profiles").join(profile_name(project)).join(browser);
|
||||
obj.insert("userDataDir".into(), dir.to_string_lossy().into_owned().into());
|
||||
}
|
||||
|
||||
/// `<folder name>-<hash of the path>`: readable, and distinct for projects whose folders share a name.
|
||||
fn profile_name(project: &Path) -> String {
|
||||
// FNV-1a: the name has to stay the same across toolchains, which `DefaultHasher` doesn't promise.
|
||||
let hash = project
|
||||
.to_string_lossy()
|
||||
.bytes()
|
||||
.fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
|
||||
let name: String = project
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
||||
.collect();
|
||||
format!("{name}-{hash:016x}")
|
||||
}
|
||||
|
||||
/// Executable directories of managed installs, read at lookup time so tools installed
|
||||
/// after startup are found without a restart: `<tool>/bin`, `<tool>/venv/bin`, npm's `.bin`.
|
||||
fn managed_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
@@ -1085,33 +1040,6 @@ mod tests {
|
||||
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_launch_gets_a_profile_of_its_project() {
|
||||
let local = Path::new("/data/local");
|
||||
let profile = |args: serde_json::Value, project: &str| {
|
||||
let mut args = args;
|
||||
set_browser_profile_in(&mut args, Path::new(project), local);
|
||||
args.get("userDataDir").cloned()
|
||||
};
|
||||
let chrome = serde_json::json!({ "type": "pwa-chrome", "request": "launch" });
|
||||
let dir = profile(chrome.clone(), "/work/shop").unwrap();
|
||||
let dir = Path::new(dir.as_str().unwrap());
|
||||
assert!(dir.starts_with("/data/local/cache/browser-profiles"), "{dir:?}");
|
||||
assert!(dir.ends_with("chrome"), "{dir:?}");
|
||||
let name = dir.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert!(name.starts_with("shop-") && name.len() == "shop-".len() + 16, "{name}");
|
||||
// The same project again: the same profile. Another folder of that name, or Edge: another one.
|
||||
assert_eq!(profile(chrome.clone(), "/work/shop").unwrap(), dir.to_string_lossy().as_ref());
|
||||
assert_ne!(profile(chrome.clone(), "/other/shop").unwrap(), dir.to_string_lossy().as_ref());
|
||||
let edge = profile(serde_json::json!({ "type": "pwa-msedge", "request": "launch" }), "/work/shop").unwrap();
|
||||
assert!(Path::new(edge.as_str().unwrap()).ends_with("msedge"), "{edge}");
|
||||
// A chosen profile (or the default one, `false`) is kept; Node and attach requests get none.
|
||||
let own = serde_json::json!({ "type": "pwa-chrome", "request": "launch", "userDataDir": false });
|
||||
assert_eq!(profile(own, "/work/shop"), Some(false.into()));
|
||||
assert_eq!(profile(serde_json::json!({ "type": "pwa-node", "request": "launch" }), "/work/shop"), None);
|
||||
assert_eq!(profile(serde_json::json!({ "type": "pwa-chrome", "request": "attach" }), "/work/shop"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_conflicts_are_recognised_for_retry() {
|
||||
let in_use = IdeError::unavailable(
|
||||
|
||||
+12
-195
@@ -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()));
|
||||
|
||||
@@ -393,16 +393,6 @@ fn no_session(s: DebugSessionId) -> IdeError {
|
||||
IdeError::not_found(format!("no debug session {}", s.0))
|
||||
}
|
||||
|
||||
/// The name of a child session: the adapter's, or the parent's when it gives none. js-debug
|
||||
/// names a browser page's session after the page the browser starts on, before the URL
|
||||
/// loads (`about:blank`, or nothing in a new profile), which says nothing about what is debugged.
|
||||
fn child_name(configuration: &Value, parent: &str) -> String {
|
||||
match configuration.get("name").and_then(Value::as_str) {
|
||||
Some(name) if !name.trim().is_empty() && name != "about:blank" => name.to_owned(),
|
||||
_ => parent.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top.
|
||||
pub fn launch_arguments(config: &LaunchConfig) -> IdeResult<Value> {
|
||||
// VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch
|
||||
@@ -754,9 +744,6 @@ impl DebugManager {
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
|
||||
let dbg = adapter::find_config(&self.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
|
||||
if config.adapter == "js-debug" {
|
||||
adapter::set_browser_profile(&mut arguments, &cwd);
|
||||
}
|
||||
let mut spec = adapter::resolve(&dbg, cwd.clone());
|
||||
if config.adapter == kotlin::ADAPTER
|
||||
&& let Some(home) = arguments
|
||||
@@ -885,7 +872,8 @@ impl DebugManager {
|
||||
request: String,
|
||||
configuration: Value,
|
||||
) -> IdeResult<()> {
|
||||
let name = child_name(&configuration, &parent.name);
|
||||
let name =
|
||||
configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone());
|
||||
let mut configuration = configuration;
|
||||
if let Some(obj) = configuration.as_object_mut() {
|
||||
obj.entry("request").or_insert_with(|| request.clone().into());
|
||||
|
||||
@@ -905,14 +905,6 @@ async fn blocked_program_input_does_not_hold_up_shutdown() {
|
||||
assert!(h.manager.send_input(session, "later").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
|
||||
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
|
||||
assert_eq!(child_name(&json!({ "name": "about:blank" }), "Shop in Chrome"), "Shop in Chrome");
|
||||
assert_eq!(child_name(&json!({ "name": "" }), "Shop in Chrome"), "Shop in Chrome");
|
||||
assert_eq!(child_name(&json!({}), "Shop in Chrome"), "Shop in Chrome");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
-46
@@ -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.
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,9 +58,6 @@ 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`).
|
||||
@@ -139,16 +135,12 @@ 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(),
|
||||
@@ -188,7 +180,6 @@ impl Host {
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
ops::agent::move_user_mcp_values(&host);
|
||||
host
|
||||
}
|
||||
|
||||
@@ -296,9 +287,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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -331,61 +330,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(
|
||||
@@ -1092,6 +1051,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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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 || {
|
||||
@@ -2170,24 +2136,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());
|
||||
|
||||
@@ -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, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
|
||||
Ok((path, typos))
|
||||
speller(&project).check(&snap.text, ®ions, &|| 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…).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}"),
|
||||
@@ -4127,25 +4130,25 @@ impl PluginApi {
|
||||
{
|
||||
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| f.note.is_some() || decoration_style(&f.style)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -4173,60 +4176,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 +4188,6 @@ fn decoration_style(style: &str) -> bool {
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "typo"
|
||||
| "weak"
|
||||
| "underline"
|
||||
| "strikethrough"
|
||||
| "muted"
|
||||
@@ -4361,11 +4308,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();
|
||||
}
|
||||
@@ -4380,8 +4324,7 @@ mod workbench_tests {
|
||||
badge: Some("abc".into()),
|
||||
style: "info".into(),
|
||||
tooltip: None,
|
||||
note: None,
|
||||
icon: None,
|
||||
note: None
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
@@ -4391,7 +4334,6 @@ mod workbench_tests {
|
||||
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| {
|
||||
@@ -4405,44 +4347,9 @@ mod workbench_tests {
|
||||
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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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:#?}");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -31,7 +31,3 @@ criterion = { version = "0.5", default-features = false }
|
||||
[[bench]]
|
||||
name = "index"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "startup"
|
||||
harness = false
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ¬if_tx, &body);
|
||||
dispatch(&inner, ¬if_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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -391,10 +391,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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -241,9 +241,6 @@ pub struct LspManager {
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
|
||||
/// Modification time of the archive behind a cached library class, so a jar
|
||||
/// replaced in place (a SNAPSHOT installed again) is read again.
|
||||
virtual_stamps: Mutex<HashMap<String, std::time::SystemTime>>,
|
||||
injections: injections::State,
|
||||
/// Open notebooks by path. Locked before `docs` when both are.
|
||||
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
|
||||
@@ -511,7 +508,6 @@ impl LspManager {
|
||||
go_mod_checked: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
virtual_stamps: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
notebooks: Mutex::new(HashMap::new()),
|
||||
})
|
||||
@@ -3206,44 +3202,14 @@ impl LspManager {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Whether the archive behind a cached library class changed since it was read.
|
||||
/// Stats the archive: call from a blocking worker.
|
||||
pub fn library_class_changed(&self, uri: &str) -> bool {
|
||||
let Some(stamp) = self.virtual_stamps.lock().get(uri).copied() else { return false };
|
||||
virtual_doc::archive_modified(uri) != Some(stamp)
|
||||
}
|
||||
|
||||
/// Archives behind cached library classes that changed since they were read.
|
||||
/// Stats each archive: call from a blocking worker.
|
||||
pub fn changed_library_archives(&self) -> Vec<PathBuf> {
|
||||
let uris: Vec<String> = self.virtual_stamps.lock().keys().cloned().collect();
|
||||
let mut archives: Vec<PathBuf> = uris
|
||||
.iter()
|
||||
.filter(|uri| self.library_class_changed(uri))
|
||||
.filter_map(|uri| virtual_doc::archive(uri))
|
||||
.collect();
|
||||
archives.sort();
|
||||
archives.dedup();
|
||||
archives
|
||||
}
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
/// `workspace/textDocumentContent` (LSP 3.18) when the server serves the
|
||||
/// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry
|
||||
/// is read from its archive, a `jar:` class shown as its library source
|
||||
/// (see [`library_source`]) or else decompiled by the server. Cached.
|
||||
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
|
||||
let cached = self.virtual_docs.lock().get(uri).cloned();
|
||||
if let Some(d) = cached {
|
||||
let stamp = self.virtual_stamps.lock().get(uri).copied();
|
||||
let current = {
|
||||
let uri = uri.to_owned();
|
||||
tokio::task::spawn_blocking(move || virtual_doc::archive_modified(&uri)).await.ok().flatten()
|
||||
};
|
||||
if stamp.is_none() || stamp == current {
|
||||
return Ok(d);
|
||||
}
|
||||
self.virtual_docs.lock().remove(uri);
|
||||
if let Some(d) = self.virtual_docs.lock().get(uri) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let scheme = virtual_doc::scheme(uri).to_owned();
|
||||
let jar_class = scheme == "jar" && library_source::parse(uri).is_some_and(|e| e.entry.ends_with(".class"));
|
||||
@@ -3387,18 +3353,11 @@ impl LspManager {
|
||||
if let Some(server) = server {
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
|
||||
}
|
||||
let stamp = virtual_doc::archive_modified(uri);
|
||||
let mut cache = self.virtual_docs.lock();
|
||||
let mut stamps = self.virtual_stamps.lock();
|
||||
if cache.len() >= VIRTUAL_CACHE_LIMIT {
|
||||
cache.clear();
|
||||
stamps.clear();
|
||||
}
|
||||
cache.insert(uri.to_owned(), doc.clone());
|
||||
match stamp {
|
||||
Some(stamp) => stamps.insert(uri.to_owned(), stamp),
|
||||
None => stamps.remove(uri),
|
||||
};
|
||||
doc
|
||||
}
|
||||
|
||||
|
||||
@@ -445,12 +445,8 @@ impl Server {
|
||||
message.push_str(":\n");
|
||||
message.push_str(&tail);
|
||||
}
|
||||
// Starting the same build again can't help: only an update can.
|
||||
let expired = self.reported_expired_build();
|
||||
let delay = if expired { None } else { self.record_crash() };
|
||||
message.push_str(if expired {
|
||||
"\nThis build has expired; update it to start it again."
|
||||
} else if delay.is_some() {
|
||||
let delay = self.record_crash();
|
||||
message.push_str(if delay.is_some() {
|
||||
"\nRestarting…"
|
||||
} else {
|
||||
"\nCrashed repeatedly; restart it manually."
|
||||
@@ -761,12 +757,6 @@ impl Server {
|
||||
crash_excerpt(&lines, n)
|
||||
}
|
||||
|
||||
/// JetBrains preview builds (kotlin-lsp) refuse to run past their expiry date and
|
||||
/// exit with "This build of kotlin-server has expired".
|
||||
fn reported_expired_build(&self) -> bool {
|
||||
self.stderr.lock().iter().any(|l| is_expired_build_line(l))
|
||||
}
|
||||
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
|
||||
@@ -921,10 +911,6 @@ fn is_error_headline(line: &str) -> bool {
|
||||
named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked")
|
||||
}
|
||||
|
||||
fn is_expired_build_line(line: &str) -> bool {
|
||||
line.contains("This build of ") && line.contains(" has expired")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1085,31 +1071,6 @@ mod tests {
|
||||
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_build_is_not_restarted() {
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
s.push_stderr(generation, "idea.system.path=/tmp/idea-system".into());
|
||||
s.push_stderr(
|
||||
generation,
|
||||
"This build of kotlin-server has expired. The IDE will now close. Please download a new build".into(),
|
||||
);
|
||||
assert_eq!(s.on_closed(generation, &sink, Some(7)), Some(None));
|
||||
assert_eq!(s.status(), ServerStatus::Crashed);
|
||||
assert!(s.status_message().unwrap().ends_with("This build has expired; update it to start it again."));
|
||||
assert!(matches!(s.state.lock().conn, Conn::Failed));
|
||||
// A manual restart (after an update) starts it again.
|
||||
s.reset_for_restart();
|
||||
assert!(s.begin_start().is_some());
|
||||
|
||||
// Any other crash is restarted.
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
s.push_stderr(generation, "Exception in thread main".into());
|
||||
assert_eq!(s.on_closed(generation, &sink, Some(1)), Some(Some(Duration::from_millis(500))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_superseded_start_is_not_announced() {
|
||||
let s = server();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! `file://` URI ⇄ path conversion (RFC 8089, VS Code-compatible encoding).
|
||||
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -24,53 +22,6 @@ const PATH: &AsciiSet = &CONTROLS
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// What RFC 3986 doesn't allow in a URI and a server may still leave unencoded in a `file:`
|
||||
/// URI (non-ASCII is always encoded); `%` stays, it starts the escapes already there.
|
||||
const LOOSE: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'[')
|
||||
.add(b'\\')
|
||||
.add(b']')
|
||||
.add(b'^')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
|
||||
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
|
||||
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
|
||||
pub fn encode_loose_file_uris(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if let Some(encoded) = encode_loose(s) {
|
||||
*s = encoded;
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
|
||||
Value::Object(map) => {
|
||||
if map.keys().any(|k| encode_loose(k).is_some()) {
|
||||
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
|
||||
}
|
||||
map.values_mut().for_each(encode_loose_file_uris);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_loose(s: &str) -> Option<String> {
|
||||
if !s.get(..5).is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) {
|
||||
return None;
|
||||
}
|
||||
match Cow::from(utf8_percent_encode(s, LOOSE)) {
|
||||
Cow::Owned(encoded) => Some(encoded),
|
||||
Cow::Borrowed(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_to_uri_string(path: &Path) -> String {
|
||||
let s = if cfg!(windows) {
|
||||
dunce::simplified(path).to_string_lossy().replace('\\', "/")
|
||||
@@ -144,28 +95,6 @@ pub use ide_core::text::is_virtual_uri;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_uris_a_server_leaves_unencoded_are_encoded() {
|
||||
// jdtls on a project under a Chinese folder (`java.usages` / references response).
|
||||
let mut v = serde_json::json!({ "result": [{
|
||||
"location": { "uri": "file:///H:/项目/online disk/A.java", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 3 } } },
|
||||
"kind": "unknown"
|
||||
}], "edit": { "changes": { "file:///tmp/目录/b.java": [] } }, "text": "项目" });
|
||||
let raw = v["result"][0]["location"].clone();
|
||||
assert!(serde_json::from_value::<lsp_types::Location>(raw).is_err(), "lsp-types rejects it as sent");
|
||||
encode_loose_file_uris(&mut v);
|
||||
let uri = v["result"][0]["location"]["uri"].as_str().unwrap();
|
||||
assert_eq!(uri, "file:///H:/%E9%A1%B9%E7%9B%AE/online%20disk/A.java");
|
||||
let location: lsp_types::Location = serde_json::from_value(v["result"][0]["location"].clone()).unwrap();
|
||||
assert!(uri_to_path(location.uri.as_str()).unwrap().ends_with("项目/online disk/A.java"));
|
||||
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
|
||||
assert_eq!(v["text"], "项目", "only file: URIs change");
|
||||
// Already encoded: untouched.
|
||||
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
|
||||
encode_loose_file_uris(&mut encoded);
|
||||
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
|
||||
@@ -172,20 +172,6 @@ pub fn description(uri: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The archive a `jar:` or `jdt:` library class comes from.
|
||||
pub(crate) fn archive(uri: &str) -> Option<std::path::PathBuf> {
|
||||
match scheme(uri) {
|
||||
"jar" => Some(crate::library_source::parse(uri)?.jar),
|
||||
"jdt" => description(uri).filter(|p| p.ends_with(".jar") || p.ends_with(".jmod")).map(Into::into),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modification time of the archive a `jar:` or `jdt:` library class comes from.
|
||||
pub(crate) fn archive_modified(uri: &str) -> Option<std::time::SystemTime> {
|
||||
std::fs::metadata(archive(uri)?).and_then(|m| m.modified()).ok()
|
||||
}
|
||||
|
||||
/// Check both the package and actual archive, so duplicate type names never pick another jar.
|
||||
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
|
||||
let segs = segments(uri);
|
||||
@@ -318,29 +304,6 @@ mod tests {
|
||||
assert_eq!(description("foo://x/y"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_archive_behind_a_library_class() {
|
||||
assert_eq!(archive(JDT), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar".into()));
|
||||
assert_eq!(
|
||||
archive("jdt://contents/java.base/java.lang/String.class?=p/%5C/jdk%5C/lib%5C/jrt-fs.jar%60java.base=x"),
|
||||
None
|
||||
);
|
||||
assert_eq!(archive("jdt://contents/a.jar/p/C.class"), None);
|
||||
assert_eq!(archive("file:///a/C.java"), None);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let jar = dir.path().join("lib-1.0-SNAPSHOT.jar");
|
||||
std::fs::write(&jar, b"PK").unwrap();
|
||||
let uri = format!("jar:file://{}!/p/C.class", jar.display());
|
||||
assert_eq!(archive(&uri), Some(jar.clone()));
|
||||
let before = archive_modified(&uri).unwrap();
|
||||
let file = std::fs::File::options().write(true).open(&jar).unwrap();
|
||||
file.set_modified(before + std::time::Duration::from_secs(5)).unwrap();
|
||||
assert_ne!(archive_modified(&uri), Some(before));
|
||||
std::fs::remove_file(&jar).unwrap();
|
||||
assert_eq!(archive_modified(&uri), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_generated_code() {
|
||||
assert!(is_decompiled(
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The discovery file, relative to the app data directory.
|
||||
@@ -40,9 +39,6 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Discovery {
|
||||
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
|
||||
#[serde(default = "loopback")]
|
||||
pub address: IpAddr,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
/// The IDE's process.
|
||||
@@ -51,10 +47,6 @@ pub struct Discovery {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
fn loopback() -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
pub fn read(path: &Path) -> io::Result<Self> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
@@ -111,13 +103,7 @@ mod tests {
|
||||
fn round_trips_privately_and_only_its_owner_removes_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = discovery_path(tmp.path());
|
||||
let ours = Discovery {
|
||||
address: "100.92.72.95".parse().unwrap(),
|
||||
port: 1234,
|
||||
token: "t".into(),
|
||||
pid: std::process::id(),
|
||||
version: "0.1.0".into(),
|
||||
};
|
||||
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
|
||||
ours.write(&path).unwrap();
|
||||
assert_eq!(Discovery::read(&path).unwrap(), ours);
|
||||
#[cfg(unix)]
|
||||
@@ -132,10 +118,6 @@ mod tests {
|
||||
ours.write(&path).unwrap();
|
||||
Discovery::remove_if_ours(&path);
|
||||
assert!(!path.exists());
|
||||
|
||||
// The file of an IDE that listened on 127.0.0.1 only has no address.
|
||||
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
|
||||
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
//! the IDE's built-in tools.
|
||||
//!
|
||||
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
|
||||
//! clients too) on `127.0.0.1:<random port>` (or another address of
|
||||
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
|
||||
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
|
||||
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
|
||||
//! `Authorization: Bearer <token>` (random, per run); requests from another
|
||||
//! `Origin` or `Host` are refused (DNS rebinding).
|
||||
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
|
||||
//! names a folder; the server picks the open project containing it.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
|
||||
//! `pid`, `version`; readable only by the user) while the server runs.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
|
||||
//! `version`; readable only by the user) while the server runs.
|
||||
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
|
||||
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
|
||||
//! persisted client configurations hold no token.
|
||||
@@ -24,7 +23,7 @@ mod server;
|
||||
pub mod stdio;
|
||||
|
||||
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
|
||||
pub use server::{McpServer, ServerOptions, local_address};
|
||||
pub use server::{McpServer, ServerOptions};
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -86,15 +85,9 @@ pub trait ToolRegistry: Send + Sync + 'static {
|
||||
) -> Result<String, ToolCallError>;
|
||||
}
|
||||
|
||||
/// The URL of the server at `127.0.0.1:<port>` for `project`.
|
||||
/// The URL of the server at `port` for `project`.
|
||||
pub fn endpoint_url(port: u16, project: &Path) -> String {
|
||||
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
|
||||
}
|
||||
|
||||
/// The URL of the server at `<address>:<port>` for `project`.
|
||||
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
|
||||
let at = std::net::SocketAddr::new(address, port);
|
||||
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
|
||||
@@ -140,9 +133,6 @@ mod tests {
|
||||
fn urls_and_commands_name_the_project() {
|
||||
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
|
||||
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
|
||||
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
|
||||
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
|
||||
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
|
||||
assert_eq!(
|
||||
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
|
||||
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
|
||||
|
||||
@@ -9,7 +9,6 @@ use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -41,8 +40,6 @@ type Body = Either<Full<Bytes>, EventStream>;
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
token: String,
|
||||
/// The address listened on (`0.0.0.0` / `::`: every address).
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
requests: protocol::Requests,
|
||||
cancel: CancellationToken,
|
||||
@@ -51,7 +48,6 @@ struct Shared {
|
||||
/// The running server. Stopping it (or dropping it) closes the port and
|
||||
/// removes its discovery file.
|
||||
pub struct McpServer {
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
/// Why the port asked for in [`ServerOptions::port`] was not used.
|
||||
port_unavailable: Option<String>,
|
||||
@@ -64,10 +60,7 @@ pub struct McpServer {
|
||||
/// How [`McpServer::start_with`] listens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerOptions {
|
||||
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
|
||||
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
|
||||
pub address: Option<IpAddr>,
|
||||
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
|
||||
/// `None`: a free port.
|
||||
pub port: Option<u16>,
|
||||
@@ -93,20 +86,15 @@ impl McpServer {
|
||||
runtime: &tokio::runtime::Handle,
|
||||
options: ServerOptions,
|
||||
) -> std::io::Result<Self> {
|
||||
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
let bind = |port: u16| {
|
||||
std::net::TcpListener::bind(SocketAddr::new(address, port))
|
||||
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
|
||||
};
|
||||
let (listener, port_unavailable) = match options.port {
|
||||
Some(port) => match bind(port) {
|
||||
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(listener) => (listener, None),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
|
||||
(bind(0)?, Some(format!("port {port}: {e}")))
|
||||
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
|
||||
}
|
||||
},
|
||||
None => (bind(0)?, None),
|
||||
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
|
||||
};
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
@@ -120,7 +108,6 @@ impl McpServer {
|
||||
};
|
||||
if let Some(path) = &discovery {
|
||||
let info = Discovery {
|
||||
address: local_address(address),
|
||||
port,
|
||||
token: token.clone(),
|
||||
pid: std::process::id(),
|
||||
@@ -133,19 +120,13 @@ impl McpServer {
|
||||
let shared = Arc::new(Shared {
|
||||
registry,
|
||||
token: token.clone(),
|
||||
address,
|
||||
port,
|
||||
requests: Default::default(),
|
||||
cancel: cancel.clone(),
|
||||
});
|
||||
runtime.spawn(accept(listener, shared, stopped));
|
||||
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
|
||||
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
}
|
||||
|
||||
/// The address listened on (`0.0.0.0` / `::`: every address).
|
||||
pub fn address(&self) -> IpAddr {
|
||||
self.address
|
||||
tracing::info!("MCP server on 127.0.0.1:{port}");
|
||||
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
@@ -162,24 +143,14 @@ impl McpServer {
|
||||
&self.token
|
||||
}
|
||||
|
||||
/// The endpoint URL for `project`, at the address a client on this computer connects to.
|
||||
/// The endpoint URL for `project`.
|
||||
pub fn url(&self, project: &Path) -> String {
|
||||
crate::endpoint_url_at(local_address(self.address), self.port, project)
|
||||
crate::endpoint_url(self.port, project)
|
||||
}
|
||||
|
||||
pub fn stop(self) {}
|
||||
}
|
||||
|
||||
/// The address a client on this computer connects to for a server listening on `address`:
|
||||
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
|
||||
pub fn local_address(address: IpAddr) -> IpAddr {
|
||||
match address {
|
||||
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
a => a,
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpServer {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.cancel();
|
||||
@@ -241,7 +212,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
if req.uri().path() != "/mcp" {
|
||||
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
|
||||
}
|
||||
if !local_request(req.headers(), shared.address, shared.port) {
|
||||
if !local_request(req.headers(), shared.port) {
|
||||
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
|
||||
}
|
||||
if !authorized(req.headers(), &shared.token) {
|
||||
@@ -372,16 +343,11 @@ impl hyper::body::Body for EventStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
|
||||
/// (loopback, the address listened on, or any when listening on every address) or as
|
||||
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
|
||||
/// even before the token check.
|
||||
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
|
||||
/// No `Origin` other than this server's own, and a loopback `Host`: a web
|
||||
/// page (DNS rebinding) can't reach the tools even before the token check.
|
||||
fn local_request(headers: &HeaderMap, port: u16) -> bool {
|
||||
let ours = |authority: &str| {
|
||||
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|
||||
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
|
||||
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
|
||||
})
|
||||
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
|
||||
};
|
||||
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
|
||||
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
|
||||
@@ -568,7 +534,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_hosts_and_origins_of_this_server_pass() {
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
let headers = |pairs: &[(header::HeaderName, &str)]| {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
@@ -576,43 +542,16 @@ mod tests {
|
||||
}
|
||||
h
|
||||
};
|
||||
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
||||
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
|
||||
assert!(local_request(&headers(&[]), loopback, 80));
|
||||
assert!(host("127.0.0.1:80", loopback));
|
||||
assert!(host("[::1]:80", loopback));
|
||||
assert!(local_request(&headers(&[]), 80));
|
||||
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
|
||||
assert!(local_request(
|
||||
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
|
||||
loopback,
|
||||
80
|
||||
));
|
||||
assert!(!host("evil.example:80", loopback));
|
||||
assert!(!host("127.0.0.1:81", loopback));
|
||||
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
|
||||
|
||||
// Listening on one address: that address too, still never a host name.
|
||||
let lan: IpAddr = "100.92.72.95".parse().unwrap();
|
||||
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
|
||||
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
|
||||
// Listening on every address: any IP address, still never a host name.
|
||||
let every: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
|
||||
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
|
||||
assert!(local_request(
|
||||
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
|
||||
every,
|
||||
80
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
|
||||
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
|
||||
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
|
||||
assert_eq!(local_address(ip("::")), ip("::1"));
|
||||
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
|
||||
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -148,14 +148,13 @@ async fn post(
|
||||
)
|
||||
};
|
||||
let info = Discovery::read(discovery).map_err(|_| not_running())?;
|
||||
let at = std::net::SocketAddr::new(info.address, info.port);
|
||||
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
|
||||
tokio::spawn(conn);
|
||||
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
|
||||
.header(header::HOST, at.to_string())
|
||||
.header(header::HOST, format!("127.0.0.1:{}", info.port))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
|
||||
.header("mcp-session-id", scope)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
|
||||
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
|
||||
s.server.take().unwrap().stop();
|
||||
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let token = "ab".repeat(32);
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
s.server =
|
||||
@@ -393,34 +393,3 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
|
||||
second.port_unavailable()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
|
||||
let mut s = setup();
|
||||
s.server.take().unwrap().stop();
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
|
||||
// Every address: clients here use loopback; other computers name the server by an address of it.
|
||||
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
|
||||
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
|
||||
let port = s.server().port();
|
||||
assert_eq!(s.server().address().to_string(), "0.0.0.0");
|
||||
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
|
||||
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
|
||||
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
|
||||
let request = |host: String| {
|
||||
format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
|
||||
bearer(&s),
|
||||
ping.len()
|
||||
)
|
||||
};
|
||||
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
|
||||
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
|
||||
|
||||
// Not an address of this computer: the server does not start, and says which address.
|
||||
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
|
||||
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
|
||||
assert!(error.to_string().contains("192.0.2.1"), "{error}");
|
||||
}
|
||||
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.8";
|
||||
pub const API_VERSION: &str = "1.7";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -234,11 +234,6 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (path, set, item);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Replace all `(path, icon)` entries of a tree decoration set at once (API 1.8).
|
||||
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
let _ = (set, items);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Trusted repository history.
|
||||
fn vcs_log(
|
||||
&self,
|
||||
@@ -1063,9 +1058,6 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
}),
|
||||
WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p),
|
||||
WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p),
|
||||
WorkbenchEvent::ProblemsChanged => {
|
||||
wit_events::Event::Other(wit_types::Extension { kind: "problems-changed".into(), data: "{}".into() })
|
||||
}
|
||||
WorkbenchEvent::Scheduled => wit_events::Event::Scheduled,
|
||||
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
|
||||
action: a.action,
|
||||
|
||||
@@ -151,67 +151,6 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an already-open document for a background edit without following a
|
||||
/// symbolic link, junction, or other Windows reparse point. Unlike ordinary
|
||||
/// project reads, background edits may have originated with another machine:
|
||||
/// accepting a link would let that machine change an unexpected file when the
|
||||
/// user later saves the editor buffer.
|
||||
///
|
||||
/// The input is deliberately project-relative. Existing path components are
|
||||
/// examined with `symlink_metadata` from the canonical project root; no
|
||||
/// canonicalization happens after the walk, so a link is never traversed.
|
||||
pub fn resolve_background_document_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() || Path::new(path).is_absolute() {
|
||||
return Err(format!("{path}: background document paths must be project-relative"));
|
||||
}
|
||||
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let components: Vec<_> = Path::new(path).components().collect();
|
||||
if components.is_empty() || components.iter().any(|part| !matches!(part, std::path::Component::Normal(_))) {
|
||||
return Err(format!("{path}: background document paths must not leave the project"));
|
||||
}
|
||||
let mut current = root.clone();
|
||||
for (index, part) in components.iter().enumerate() {
|
||||
let std::path::Component::Normal(name) = part else { unreachable!() };
|
||||
if background_control_name(name) {
|
||||
return Err(format!("{path}: background edits may not target VCS or IDE control files"));
|
||||
}
|
||||
current.push(name);
|
||||
let metadata = std::fs::symlink_metadata(¤t).map_err(|_| format!("{path}: no such open document"))?;
|
||||
if is_link_or_reparse(&metadata) {
|
||||
return Err(format!("{path}: background edits refuse symbolic links"));
|
||||
}
|
||||
if index + 1 < components.len() && !metadata.is_dir() {
|
||||
return Err(format!("{path}: not a directory"));
|
||||
}
|
||||
if index + 1 == components.len() && !metadata.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// [`resolve_background_document_in_root`] over a workspace group's roots.
|
||||
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
|
||||
first_root_resolving(roots, path, resolve_background_document_in_root)
|
||||
}
|
||||
|
||||
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
let reparse = {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let reparse = false;
|
||||
metadata.file_type().is_symlink() || reparse
|
||||
}
|
||||
|
||||
fn background_control_name(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
|
||||
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
|
||||
}
|
||||
|
||||
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
|
||||
/// without requiring it to exist: `Ok(true)` also for a missing path, so
|
||||
/// callers distinguish "outside" from "absent". Existing symlinks are
|
||||
@@ -766,25 +705,6 @@ mod tests {
|
||||
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_document_edits_are_relative_and_exclude_control_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(root.join(".openidea/state"), "state\n").unwrap();
|
||||
|
||||
assert!(resolve_background_document_in_root(&root, "src/main.rs").is_ok());
|
||||
for path in
|
||||
["/etc/passwd", "../outside", "src/../.git/hooks/pre-commit", ".git/hooks/pre-commit", ".openidea/state"]
|
||||
{
|
||||
assert!(resolve_background_document_in_root(&root, path).is_err(), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
|
||||
for path in [
|
||||
@@ -820,23 +740,6 @@ mod tests {
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn background_document_edits_refuse_final_and_ancestor_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
symlink(root.join("src/main.rs"), root.join("final-link.rs")).unwrap();
|
||||
symlink(root.join("src"), root.join("linked-src")).unwrap();
|
||||
|
||||
assert!(resolve_background_document_in_root(&root, "final-link.rs").unwrap_err().contains("symbolic links"));
|
||||
assert!(
|
||||
resolve_background_document_in_root(&root, "linked-src/main.rs").unwrap_err().contains("symbolic links")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_paths_follow_existing_and_dangling_symlinks() {
|
||||
|
||||
@@ -119,13 +119,11 @@ pub enum EventKind {
|
||||
EditorFocused,
|
||||
ActionPerformed,
|
||||
VcsChanged,
|
||||
/// Problems of the window's files changed (a language server or a report).
|
||||
ProblemsChanged,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 17] = [
|
||||
pub const ALL: [Self; 16] = [
|
||||
Self::DocumentChanged,
|
||||
Self::WebsocketReady,
|
||||
Self::FileClosed,
|
||||
@@ -141,7 +139,6 @@ impl EventKind {
|
||||
Self::EditorFocused,
|
||||
Self::ActionPerformed,
|
||||
Self::VcsChanged,
|
||||
Self::ProblemsChanged,
|
||||
Self::Scheduled,
|
||||
];
|
||||
|
||||
@@ -162,7 +159,6 @@ impl EventKind {
|
||||
Self::EditorFocused => "editor-focused",
|
||||
Self::ActionPerformed => "action-performed",
|
||||
Self::VcsChanged => "vcs-changed",
|
||||
Self::ProblemsChanged => "problems-changed",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
@@ -298,8 +294,6 @@ pub enum WorkbenchEvent {
|
||||
EditorFocused(Option<String>),
|
||||
ActionPerformed(ActionPerformed),
|
||||
VcsChanged(String),
|
||||
/// Problems of the window's files changed; plugins re-read `problems`.
|
||||
ProblemsChanged,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
@@ -321,7 +315,6 @@ impl WorkbenchEvent {
|
||||
Self::EditorFocused(_) => EventKind::EditorFocused,
|
||||
Self::ActionPerformed(_) => EventKind::ActionPerformed,
|
||||
Self::VcsChanged(_) => EventKind::VcsChanged,
|
||||
Self::ProblemsChanged => EventKind::ProblemsChanged,
|
||||
Self::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
@@ -932,9 +925,6 @@ pub struct FileDecoration {
|
||||
/// Text shown after the name (API 1.7 `set-file-note`).
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
/// `[[contributes.fileIcons]]` id for this file or folder (API 1.8 `set-file-icon`).
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
/// Commit metadata, date in Unix seconds.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
|
||||
return Ok(Err(wit::Error::Invalid("edits too large".into())));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
let path = match self.resolve_background_document_in_project(&root, &path) {
|
||||
let path = match self.resolve_in_project(&root, &path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
|
||||
};
|
||||
@@ -936,14 +936,6 @@ impl StoreData {
|
||||
sandbox::resolve_writable_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
|
||||
})
|
||||
}
|
||||
/// A background document edit is the only plugin operation that changes an
|
||||
/// editor on behalf of a remote peer. It therefore rejects links and IDE
|
||||
/// control directories instead of using the normal read resolver.
|
||||
fn resolve_background_document_in_project(&self, root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
sandbox::resolve_background_document_in_root(root, path).or_else(|root_error| {
|
||||
sandbox::resolve_background_document_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
|
||||
})
|
||||
}
|
||||
/// Whether a `project` resource's path lands inside the resource's root or
|
||||
/// any member of its workspace group, without requiring it to exist.
|
||||
fn inside_project(&self, root: &Path, path: &str) -> Result<bool, String> {
|
||||
@@ -2505,13 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
.file_decorations(
|
||||
Some(&real),
|
||||
&set_id,
|
||||
Some(crate::FileDecoration {
|
||||
badge: item.badge,
|
||||
style: item.style,
|
||||
tooltip: item.tooltip,
|
||||
note: None,
|
||||
icon: None,
|
||||
}),
|
||||
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
@@ -2529,36 +2515,9 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
fn set_file_icon(&mut self, path: String, set_id: String, icon: String, tooltip: Option<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let real = match self.decoration_path(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: None, icon: Some(icon) };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
fn set_file_icons(&mut self, set_id: String, items: Vec<(String, String)>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if items.len() > 10_000 {
|
||||
return Ok(Err(wit::Error::Invalid("at most 10000 file icons per set".into())));
|
||||
}
|
||||
let mut resolved = Vec::with_capacity(items.len());
|
||||
for (path, icon) in items {
|
||||
match self.decoration_path(&path) {
|
||||
Ok(real) => resolved.push((real, icon)),
|
||||
Err(e) => return Ok(Err(e)),
|
||||
}
|
||||
}
|
||||
Ok(self.host.file_icon_decorations(&set_id, resolved).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod language_features_bindings {
|
||||
|
||||
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.8.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
|
||||
let host = v("1.7.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.9") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
let newer = check_api(&v("1.8.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.8 SDK"), "{older}");
|
||||
assert!(older.contains("rebuild it with the 1.7 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
|
||||
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
|
||||
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
|
||||
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
|
||||
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
|
||||
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
|
||||
@@ -21,27 +21,6 @@ impl Plugin for Fixture {
|
||||
&[Diagnostic::new(Range::default(), Severity::Info, "Manual problem")],
|
||||
)
|
||||
.map(|()| None),
|
||||
// The host-owned snapshot, one entry per producer: what `problems` reads.
|
||||
"problems" => {
|
||||
let files: Vec<String> = host::problems(None)?
|
||||
.into_iter()
|
||||
.map(|f| format!("{}:{}:{}", f.path, f.source, f.diagnostics.len()))
|
||||
.collect();
|
||||
Ok(Some(files.join("|")))
|
||||
}
|
||||
// A line-end annotation in the severity styles the editor draws: the host must
|
||||
// keep `typo` and `weak` instead of dropping them as unknown.
|
||||
"decorate" => {
|
||||
let path = arg.unwrap_or_default();
|
||||
let item = |style: &str, after: &str| openidea_plugin::Decoration {
|
||||
range: Range::default(),
|
||||
style: style.into(),
|
||||
hover: None,
|
||||
after: Some(after.into()),
|
||||
};
|
||||
host::set_decorations(path, "fixture", &[item("typo", "typo message"), item("weak", "weak message")])
|
||||
.map(|()| None)
|
||||
}
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
|
||||
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
|
||||
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
|
||||
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
|
||||
Loaded 100 of 636 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user