дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-13 21:25:47 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
953b51b8c6 |
No files matched your search
@@ -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
|
||||
|
||||
Створено
@@ -4288,7 +4288,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+13
-4
@@ -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() });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -673,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`,
|
||||
@@ -765,7 +743,6 @@ impl Default for AppearanceSettings {
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
background_image: BackgroundImageSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -309,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.
|
||||
@@ -356,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 || {
|
||||
@@ -2164,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…).
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -3176,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)
|
||||
}
|
||||
@@ -3312,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()
|
||||
@@ -3325,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}"),
|
||||
@@ -4129,23 +4135,16 @@ impl PluginApi {
|
||||
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
|
||||
.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()));
|
||||
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,
|
||||
@@ -4189,8 +4188,6 @@ fn decoration_style(style: &str) -> bool {
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "typo"
|
||||
| "weak"
|
||||
| "underline"
|
||||
| "strikethrough"
|
||||
| "muted"
|
||||
@@ -4311,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();
|
||||
}
|
||||
@@ -4356,29 +4350,6 @@ mod workbench_tests {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -675,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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -481,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");
|
||||
|
||||
@@ -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};
|
||||
@@ -42,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 {
|
||||
@@ -95,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 {
|
||||
@@ -116,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 {
|
||||
@@ -154,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,
|
||||
@@ -553,124 +161,49 @@ impl IndexData {
|
||||
self.by_path.get(path).copied()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -680,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)) {
|
||||
@@ -701,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();
|
||||
@@ -733,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 {
|
||||
@@ -748,101 +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 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]
|
||||
@@ -850,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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -173,7 +173,7 @@ impl Shared {
|
||||
let root = filter.root().to_owned();
|
||||
match store.load() {
|
||||
Ok(records) => {
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
|
||||
let entries: Vec<FileEntry> = records
|
||||
.into_par_iter()
|
||||
.filter(|(rel, _)| {
|
||||
let path = root.join(rel);
|
||||
@@ -182,8 +182,8 @@ impl Shared {
|
||||
.map(|(rel, rec)| FileEntry::new(root.join(&rel), 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);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("index cache load failed: {e}"),
|
||||
@@ -321,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;
|
||||
@@ -337,11 +338,11 @@ 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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -79,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")),
|
||||
@@ -1047,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(
|
||||
|
||||
@@ -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;
|
||||
@@ -1058,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
|
||||
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
|
||||
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
|
||||
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
|
||||
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
|
||||
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
|
||||
@@ -21,31 +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())),
|
||||
}
|
||||
}
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
|
||||
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
|
||||
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
|
||||
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
|
||||
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
|
||||
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
|
||||
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
|
||||
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
|
||||
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
|
||||
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
|
||||
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
|
||||
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
|
||||
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
|
||||
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
|
||||
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.8.0;
|
||||
export openidea:plugin/providers@1.8.0;
|
||||
export openidea:plugin/file-actions@1.8.0;
|
||||
include openidea:plugin/plugin-syntax@1.7.0;
|
||||
export openidea:plugin/providers@1.7.0;
|
||||
export openidea:plugin/file-actions@1.7.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
|
||||
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
|
||||
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
67660f5ebbfdd3967fabcc0b5c41f215d4a38d9092b91eb0166f12bccfdffffa
|
||||
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
|
||||
Бінарний файл не відображається.
Loaded 100 of 455 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача