Compare commits

..
Author SHA1 Message Date
l0ng-ai 07e9ccb4dd fix(plugins): keep each stored value in its own file
A set rewrote the whole storage.json, up to 64 MiB, for one small key, and
a get parsed all of it. Each key now has its own file (named by its hash,
written atomically); an older storage.json moves into them on first use.
2026-11-07 18:58:25 +08:00
786 changed files with 4580 additions and 62634 deletions

No files matched your search

Generated
+53 -6
View File
@@ -4598,7 +4598,6 @@ dependencies = [
"encoding_rs",
"ide-build",
"ide-core",
"ide-language",
"libc",
"parking_lot",
"portable-pty",
@@ -4677,6 +4676,7 @@ dependencies = [
"keyring-core",
"parking_lot",
"russh",
"russh-config",
"russh-sftp",
"serde",
"serde_json",
@@ -4783,14 +4783,12 @@ dependencies = [
"ignore",
"notify",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"trash",
"windows-sys 0.61.2",
"zip",
]
@@ -5428,7 +5426,10 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"bitflags 2.13.2",
"libc",
"plain",
"redox_syscall 0.9.5",
]
[[package]]
@@ -6603,7 +6604,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -6773,6 +6774,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.10.2"
@@ -7230,6 +7237,15 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "redox_syscall"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "redox_users"
version = "0.5.3"
@@ -7514,6 +7530,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "russh-config"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993346101d4741a59c82bafc6b645c52ce3e7b0820f13e4b5d48cdb591e3d955"
dependencies = [
"futures",
"globset",
"log",
"thiserror 2.0.21",
"tokio",
"whoami 1.6.1",
]
[[package]]
name = "russh-cryptovec"
version = "0.62.0"
@@ -8331,7 +8361,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -8595,7 +8625,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.21",
"tracing",
"whoami",
"whoami 2.1.3",
]
[[package]]
@@ -10537,6 +10567,12 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@@ -11111,6 +11147,17 @@ dependencies = [
"libc",
]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"web-sys",
]
[[package]]
name = "whoami"
version = "2.1.3"
+4
View File
@@ -76,4 +76,8 @@ the [official plugins](plugins/official). The plugin API is still a preview.
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
Database tool window notes, including Oracle Instant Client setup, are in
[`docs/databases.md`](docs/databases.md).
Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+40 -282
View File
@@ -109,9 +109,6 @@ struct Shared {
struct Known {
#[serde(default)]
config: Vec<AgentConfigOption>,
/// Thinking levels reported for each model value, including its last choice.
#[serde(default, rename = "thoughtLevels")]
thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// By project root: skills and custom commands differ per project.
#[serde(default, rename = "projectCommands")]
commands: HashMap<String, Vec<AgentCommand>>,
@@ -131,7 +128,10 @@ impl Shared {
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
// Model-specific options are offered separately, never as provider-wide defaults.
// A thinking level's values depend on the model (low/medium/high on one, none or a
// different set on another), but what ran last is remembered wholesale — after a
// model switch the New Task page would offer the previous model's levels. Until the
// session reports the new model's own, don't show the option at all.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config::add_client_options(provider, &mut config);
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
@@ -155,31 +155,14 @@ impl Shared {
self.paths.project_root.to_string_lossy().into_owned()
}
/// Cache levels only when `model` confirms which model the session reported them for.
fn remember(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) {
let snapshot = {
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
if let Some(c) = config.filter(|c| !c.is_empty() && known.config != *c) {
known.config = c.to_vec();
changed = true;
}
let root = self.root_key();
if let Some(c) =
@@ -218,9 +201,6 @@ struct TaskFile {
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
#[serde(default, skip_serializing_if = "is_zero")]
pending_rollback: u32,
/// The user named the task: its agent's own title updates no longer replace it.
#[serde(default, skip_serializing_if = "is_false")]
title_renamed: bool,
}
struct TaskState {
@@ -230,8 +210,6 @@ struct TaskState {
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
pending_rollback: u32,
/// The user named the task; see `TaskFile::title_renamed`.
title_renamed: bool,
/// A turn is running or about to (prompts arriving now are queued).
busy: bool,
/// Background tasks the agent reported as running (`run_in_background` commands and
@@ -243,7 +221,6 @@ struct TaskState {
/// A running agent and its session.
#[derive(Clone)]
struct Live {
sign_in_message: String,
peer: acp::Conn,
session: String,
capabilities: AgentCapabilities,
@@ -362,8 +339,6 @@ impl AgentManager {
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
info.thought_levels =
self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default();
info.commands = self.shared.known_commands(&p.id);
info
})
@@ -483,18 +458,6 @@ impl AgentManager {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
{
config.extend(levels.iter().cloned().map(|mut o| {
if let Some(choice) =
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
{
o.current = Some(choice.value.clone());
}
o
}));
}
let now = now_ms();
let task = AgentTask {
project_root,
@@ -528,7 +491,6 @@ impl AgentManager {
before,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
};
let handle = Task::restore(file, self.shared.clone());
handle.state.lock().busy = true;
@@ -643,8 +605,6 @@ impl AgentManager {
o.clone()
};
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
let mut model_options =
target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str());
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
@@ -652,14 +612,10 @@ impl AgentManager {
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
if let Some(options) = reply.get("configOptions") {
config::merge_update(&mut task.state.lock().task.config, options);
model_options = true;
}
}
{
let mut s = task.state.lock();
if !model_options {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
@@ -668,9 +624,8 @@ impl AgentManager {
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let provider = s.task.provider.clone();
let config = s.task.config.clone();
let model = s.task.model.clone().filter(|_| model_options);
drop(s);
task.shared.remember(&provider, Some(&config), None, model.as_deref());
task.shared.remember(&provider, Some(&config), None);
}
task.approve_pending();
task.persist();
@@ -732,7 +687,6 @@ impl AgentManager {
before: (*s.before).clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: total - keep,
title_renamed: s.title_renamed,
}
};
let task = Task::restore(file, self.shared.clone());
@@ -751,38 +705,12 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("wait until the agent is done"));
}
let starts = turn_starts(&task.state.lock().conversation.main.entries);
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
// Dropping every turn starts the conversation over, which needs no agent-side
// rewind — and some agents cannot rewind past their first turn (a resumed
// Claude Code session). A fresh session avoids both.
if turns == 0 {
task.kill();
let first = {
let mut s = task.state.lock();
let first = match s.conversation.main.entries.get(cut) {
Some(AgentEntry::User { text, .. }) => {
Some(AgentPrompt { text: text.clone(), ..Default::default() })
}
_ => None,
};
s.session_id = None;
s.conversation = Conversation::default();
// The fresh session reports its own usage; nothing carries over.
s.task.usage = None;
s.pending_rollback = 0;
s.task.updated_ms = now_ms();
first
};
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
task.emit_task();
return Ok(first);
}
let live = task.ensure_session().await.map_err(IdeError::internal)?;
if !live.capabilities.rollback {
return Err(IdeError::invalid("this agent can't undo turns"));
}
let starts = turn_starts(&task.state.lock().conversation.main.entries);
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
let drop = (starts.len() - turns as usize) as u32;
let _: Value = live
.peer
@@ -943,28 +871,6 @@ impl AgentManager {
Ok(())
}
/// Renames the task; the agent's own later title updates keep the user's choice.
pub fn rename(&self, id: &str, title: &str) -> IdeResult<()> {
let title = title.trim();
if title.is_empty() {
return Err(IdeError::invalid("the title is empty"));
}
let title: String = title.chars().take(200).collect();
let task = self.task(id)?;
{
let mut s = task.state.lock();
if s.task.title == title && s.title_renamed {
return Ok(());
}
s.task.title = title;
s.title_renamed = true;
s.task.updated_ms = now_ms();
}
task.persist();
task.emit_task();
Ok(())
}
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
let task = self.task(id)?;
@@ -1035,7 +941,6 @@ impl Task {
wanted: file.wanted,
before: Arc::new(file.before),
pending_rollback: file.pending_rollback,
title_renamed: file.title_renamed,
busy: false,
background_tasks: Vec::new(),
}),
@@ -1147,7 +1052,6 @@ impl Task {
before: (*s.before).clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: s.pending_rollback,
title_renamed: s.title_renamed,
})
};
let path = self.shared.paths.store_dir.join(format!("{}.json", self.id));
@@ -1397,7 +1301,11 @@ impl Task {
match e {
// Signed out, or the account was refused (Claude Code: "API Error: 403").
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
let provider = self.state.lock().task.provider.clone();
Failure::from(match self.shared.spec(&provider) {
Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint),
None => message,
})
}
e => Failure::from(self.describe(e)),
}
@@ -1415,11 +1323,6 @@ impl Task {
(s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone())
};
let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?;
let launch_cwd = cwd.clone();
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
.await
.map_err(|e| e.to_string())?;
let sign_in_message = spec.sign_in_message();
self.stderr.lock().clear();
self.config_sent.lock().clear();
let weak = Arc::downgrade(self);
@@ -1465,7 +1368,7 @@ impl Task {
.await
.map_err(|e| match e {
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
sign_in_message.clone()
format!("{} needs you to sign in. {}", spec.name, spec.login_hint)
}
e => self.describe(e),
})?;
@@ -1491,21 +1394,11 @@ impl Task {
// A session that reports none keeps the last known ones, but what is set is unknown.
let reported = config::options_of(&result);
let known = !reported.is_empty();
if known {
self.shared.remember(&provider_id, Some(&reported), None, model_of(&reported).as_deref());
}
let (mut options, mut wanted) = {
let (mut options, wanted) = {
let s = self.state.lock();
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
config::add_client_options(&provider_id, &mut options);
// Changing model can replace the effort choices; validate those only afterwards.
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id).and_then(|o| o.category.as_deref()) {
Some("model") => 0,
Some("thought_level") => 2,
_ => 1,
});
let mut model_options = known;
for choice in &wanted {
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
@@ -1515,9 +1408,6 @@ impl Task {
}
continue;
}
if o.category.as_deref() == Some("thought_level") && !model_options {
continue;
}
let already = known && o.current.as_deref() == Some(choice.value.as_str());
if already || !o.values.iter().any(|v| v.value == choice.value) {
continue;
@@ -1529,12 +1419,6 @@ impl Task {
Ok(reply) => {
if let Some(fresh) = reply.get("configOptions") {
config::merge_update(&mut options, fresh);
model_options = true;
} else if o.category.as_deref() == Some("model") {
if o.current.as_deref() != Some(choice.value.as_str()) {
options.retain(|o| o.category.as_deref() != Some("thought_level"));
}
model_options = false;
}
if let Some(o) = options.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
@@ -1543,8 +1427,7 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
}
}
let model = model_of(&options).filter(|_| model_options);
self.shared.remember(&provider_id, Some(&options), None, model.as_deref());
self.shared.remember(&provider_id, Some(&options), None);
{
let mut s = self.state.lock();
s.task.model = model_of(&options).or(s.task.model.take());
@@ -1557,7 +1440,7 @@ impl Task {
}
self.persist();
self.emit_task();
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -1593,19 +1476,11 @@ impl Task {
let mut s = task.state.lock();
if let Some(fresh) = reply.get("configOptions") {
config::merge_update(&mut s.task.config, fresh);
} else if option.category.as_deref() == Some("model") {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) {
o.current = Some(value);
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let model = s.task.model.clone().filter(|_| {
reply.get("configOptions").is_some() || option.category.as_deref() != Some("model")
});
let (provider, options) = (s.task.provider.clone(), s.task.config.clone());
drop(s);
task.shared.remember(&provider, Some(&options), None, model.as_deref());
}
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
}
@@ -1665,18 +1540,12 @@ impl Task {
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt,
// unless the user named the task.
// The agent's own title for the session replaces the first line of the prompt.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
if let Some(title) = title {
// Check and write under one lock: a rename landing between them would be lost.
let mut s = self.state.lock();
if !s.title_renamed && s.task.title != title {
s.task.title = title.to_owned();
drop(s);
self.persist();
self.emit_task();
}
self.state.lock().task.title = title.to_owned();
self.persist();
self.emit_task();
}
return;
}
@@ -1710,15 +1579,11 @@ impl Task {
}
};
if task_changed {
let (config, commands, model) = {
let (config, commands) = {
let s = self.state.lock();
(
s.task.config.clone(),
s.task.commands.clone(),
s.task.model.clone().filter(|_| kind == "config_option_update"),
)
(s.task.config.clone(), s.task.commands.clone())
};
self.shared.remember(&provider, Some(&config), Some(&commands), model.as_deref());
self.shared.remember(&provider, Some(&config), Some(&commands));
if kind != "usage_update" {
self.persist();
}
@@ -1755,12 +1620,12 @@ impl Task {
/// Blocks the calling (blocking-pool) thread until the user answers.
fn ask_permission(self: &Arc<Self>, params: &Value) -> Value {
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
let (questions, by_index) = asked_questions(params);
let title = permission_title(params);
// An agent that answers by index titles its request "Ask user N question(s)"; its
// first question says what it wants, as the Claude Code adapter titles its own.
let title =
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
let title = params
.pointer("/toolCall/title")
.and_then(Value::as_str)
.filter(|t| !t.is_empty())
.unwrap_or("The agent asks for permission")
.to_owned();
// A subagent's request is answered in the task's conversation, named after the subagent.
let session = params.get("sessionId").and_then(Value::as_str);
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
@@ -1782,10 +1647,11 @@ impl Task {
.collect()
})
.unwrap_or_default();
let asked: Vec<String> = questions
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
.collect();
let questions: Vec<AgentQuestion> = params
.pointer("/toolCall/_meta/questions")
.and_then(|questions| serde_json::from_value(questions.clone()).ok())
.unwrap_or_default();
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
@@ -1798,7 +1664,7 @@ impl Task {
.as_deref()
.filter(|_| !asked.is_empty())
.and_then(|reply| serde_json::from_str::<serde_json::Map<String, Value>>(reply).ok());
let name = match &answers {
let name = match answers {
Some(answers) => asked
.iter()
.filter_map(|question| answers.get(question))
@@ -1824,31 +1690,6 @@ impl Task {
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
self.set_status(AgentStatus::Running, None);
}
// An agent that answers by index reads the answers beside the outcome, keyed by
// question index, with its Submit option chosen; anything less cancels the request.
if by_index {
let submit = options.iter().find(|option| option.kind == "allow_once").or_else(|| options.first());
return match (answers.as_ref(), submit) {
(Some(answers), Some(submit)) => {
let picked: serde_json::Map<String, Value> = asked
.iter()
.enumerate()
.filter_map(|(index, question)| {
let items = answers.get(question)?;
let answer = match items {
Value::Array(items) => {
items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")
}
item => item.as_str().unwrap_or_default().to_owned(),
};
Some((index.to_string(), json!(answer)))
})
.collect();
json!({ "outcome": { "outcome": "selected", "optionId": submit.id }, "answers": picked })
}
_ => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
match answer {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
@@ -1862,10 +1703,7 @@ impl Task {
}
let mut pending = self.permissions.lock();
for entry in s.conversation.main.entries.iter().rev() {
// A request with questions is never satisfied by a bypass: its Submit option
// would answer with no answers.
if let AgentEntry::Permission { id, options, questions, chosen: None, .. } = entry
&& questions.is_empty()
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
&& let Some(reply) = pending.remove(id)
{
@@ -1930,82 +1768,6 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
}
fn permission_title(params: &Value) -> String {
let call = &params["toolCall"];
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
if call.get("kind").and_then(Value::as_str) == Some("execute") {
if let Some(command) = call.pointer("/rawInput/command").and_then(Value::as_str).filter(|c| !c.is_empty()) {
// Network approvals describe the host rather than approving command execution.
if !title.is_some_and(|t| t.starts_with("Allow network access to ")) {
return format!("Run {}", shell_script(command).unwrap_or_else(|| command.to_owned()));
}
}
if title.is_none()
&& let Some(content) = call.get("content").and_then(Value::as_array)
&& let Some(text) = content.iter().find_map(|c| c.pointer("/content/text").and_then(Value::as_str))
{
return format!("Run {text}");
}
}
title.unwrap_or("The agent asks for permission").to_owned()
}
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
pub(crate) fn shell_script(command: &str) -> Option<String> {
let (shell, rest) = command.split_once(' ')?;
if !matches!(shell.rsplit('/').next(), Some("sh" | "bash" | "zsh")) {
return None;
}
let quoted = rest.strip_prefix("-lc ").or_else(|| rest.strip_prefix("-c "))?;
let mut out = String::new();
let mut chars = quoted.chars();
// Adjacent quoted words, e.g. 'a'\''b' or "a \"b\"".
while let Some(c) = chars.next() {
match c {
'\'' => loop {
match chars.next()? {
'\'' => break,
c => out.push(c),
}
},
'"' => loop {
match chars.next()? {
'"' => break,
'\\' => match chars.next()? {
c @ ('"' | '\\' | '$' | '`') => out.push(c),
'\n' => {}
c => {
out.push('\\');
out.push(c);
}
},
c => out.push(c),
}
},
'\\' => out.push(chars.next()?),
_ => return None,
}
}
Some(out)
}
/// The questions a permission request asks, and whether they answer by question index.
/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id;
/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside
/// the id of its Submit option.
fn asked_questions(params: &Value) -> (Vec<AgentQuestion>, bool) {
for (pointer, by_index) in [("/toolCall/_meta/questions", false), ("/toolCall/_meta/qwenQuestions", true)] {
if let Some(questions) = params
.pointer(pointer)
.and_then(|questions| serde_json::from_value::<Vec<AgentQuestion>>(questions.clone()).ok())
.filter(|questions| !questions.is_empty())
{
return (questions, by_index);
}
}
(Vec::new(), false)
}
/// ACP content blocks: the text, each file (embedded when its text is in `contents`, else
/// a `resource_link`), then the images.
fn blocks(prompt: &AgentPrompt, contents: &[Option<String>]) -> Vec<Value> {
@@ -2121,10 +1883,6 @@ fn turn_starts(entries: &[AgentEntry]) -> Vec<usize> {
.collect()
}
fn is_false(b: &bool) -> bool {
!*b
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
-6
View File
@@ -282,9 +282,6 @@ pub struct AgentPermissionOption {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestion {
/// Stable answer identity when the provider supplies question ids.
#[serde(default)]
pub answer_key: Option<String>,
#[serde(default)]
pub header: String,
pub question: String,
@@ -409,9 +406,6 @@ pub struct AgentProvider {
pub env: BTreeMap<String, String>,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
pub config: Vec<AgentConfigOption>,
/// Thinking-level options by model value; absent models have not reported their levels.
#[serde(default)]
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// Slash commands it offered last time.
pub commands: Vec<AgentCommand>,
pub source: AgentSource,
+24 -67
View File
@@ -822,7 +822,7 @@ impl CodexShim {
match method {
"item/commandExecution/requestApproval" => {
let choices = translate::decisions(params, true);
let title = translate::command_approval_title(params, item.as_ref());
let title = translate::command_approval_title(params, item.as_ref(), cwd);
let call = tool_call_of(
&item_id,
&title,
@@ -871,55 +871,31 @@ impl CodexShim {
Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" })))
}
"item/tool/requestUserInput" => {
let questions = params.get("questions").and_then(Value::as_array).cloned().unwrap_or_default();
// One permission request carrying all the questions, so the client shows
// them as a card (as it does for Claude's `AskUserQuestion`).
let asked: Vec<Value> = questions
.iter()
.filter_map(|q| {
let id = q.get("id").and_then(Value::as_str)?;
let question = q.get("question").and_then(Value::as_str)?;
let options: Vec<Value> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| {
let label = o.get("label").and_then(Value::as_str)?;
Some(json!({
"label": label,
"description": o.get("description").and_then(Value::as_str).unwrap_or_default(),
}))
})
.collect();
Some(json!({
"answerKey": id,
"header": q.get("header").and_then(Value::as_str).unwrap_or_default(),
"question": question,
"multiSelect": q.get("multiSelect").and_then(Value::as_bool).unwrap_or(false),
"options": options,
}))
})
.collect();
if asked.is_empty() {
return Ok(json!({ "answers": {} }));
}
let title = asked[0].get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
let mut call = tool_call_of(&item_id, title, "other", None, None);
call["_meta"] = json!({ "questions": asked });
let options = [json!({ "optionId": "submit", "name": "Submit", "kind": "allow_once" })];
let picked: serde_json::Map<String, Value> = self
.ask_option(&thread, call, &options)
.and_then(|reply| serde_json::from_str(&reply).ok())
.unwrap_or_default();
let mut answers = serde_json::Map::new();
for q in &questions {
for q in params.get("questions").and_then(Value::as_array).into_iter().flatten() {
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
if let Some(labels) = picked
.get(qid)
.filter(|labels| labels.as_array().is_some_and(|labels| labels.iter().all(Value::is_string)))
{
answers.insert(qid.to_owned(), json!({ "answers": labels }));
let choices: Vec<Choice> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.filter_map(|(i, o)| {
let label = o.get("label")?.as_str()?;
Some(Choice {
option: json!({ "optionId": i.to_string(), "name": label, "kind": "allow_once" }),
answer: json!({ "answers": [label] }),
})
})
.collect();
if choices.is_empty() {
continue;
}
let title = q.get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
let call = tool_call_of(&item_id, title, "other", None, None);
let answer = self.ask(&thread, call, &choices, Value::Null);
if !answer.is_null() {
answers.insert(qid.to_owned(), answer);
}
}
Ok(json!({ "answers": answers }))
@@ -952,25 +928,6 @@ impl CodexShim {
}
}
/// Asks the client; returns the selected option id (`None` when cancelled).
fn ask_option(&self, session: &str, tool_call: Value, options: &[Value]) -> Option<String> {
let client = self.client.get().cloned()?;
let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options });
let outcome: Result<Value, RpcError> =
self.runtime.block_on(client.request("session/request_permission", params, None));
match outcome {
Ok(outcome) => outcome
.pointer("/outcome/outcome")
.and_then(Value::as_str)
.filter(|outcome| *outcome == "selected")
.and_then(|_| outcome.pointer("/outcome/optionId").and_then(Value::as_str).map(str::to_owned)),
Err(e) => {
tracing::debug!("codex: question request failed: {e}");
None
}
}
}
/// Asks the client; returns the answer for codex (`otherwise` when cancelled).
fn ask(&self, session: &str, tool_call: Value, choices: &[Choice], otherwise: Value) -> Value {
let Some(client) = self.client.get().cloned() else { return otherwise };
+2 -61
View File
@@ -247,39 +247,13 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() {
assert_eq!(
command_approval_title(
&json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}),
None,
None
),
"Run ls"
"Run touch b"
);
}
#[test]
fn approval_title_shows_the_script_inside_a_shell_wrapper() {
let title = |command: &str| command_approval_title(&json!({ "command": command }), None);
assert_eq!(
title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#),
"Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'"
);
assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'");
assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls");
assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x");
assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'");
}
#[test]
fn approval_command_uses_its_request_and_matching_item_only() {
let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'";
let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls",
"commandActions": [{"type": "listFiles", "path": "."}]});
let request = json!({"itemId": "write", "command": command});
assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}"));
let item = json!({"id": "write", "command": command});
let request = json!({"itemId": "write"});
assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}"));
assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command");
assert_eq!(command_approval_title(&request, None), "Run a command");
}
#[test]
fn history_replays_turns() {
let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [
@@ -342,39 +316,6 @@ impl Client {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn questions_share_one_card_and_keep_their_ids() {
let client = Arc::new(Client::default());
client.answers.lock().extend([Some(r#"{"first":["One"],"second":["Two","Custom"]}"#), None, Some("invalid")]);
let (ours, theirs) = tokio::io::duplex(1 << 16);
let (read, write) = tokio::io::split(theirs);
let codex = Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines);
let acp = attach(client.clone(), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let params = json!({"threadId": "s", "itemId": "q", "questions": [
{"id": "first", "question": "Which?", "options": [{"label": "One", "description": "First"}]},
{"id": "second", "question": "Which?", "multiSelect": true, "options": [{"label": "Two"}]}
]});
for expected in
[json!({"first": {"answers": ["One"]}, "second": {"answers": ["Two", "Custom"]}}), json!({}), json!({})]
{
let reply: Value = codex.request("item/tool/requestUserInput", params.clone(), None).await.unwrap();
assert_eq!(reply, json!({"answers": expected}));
}
let asked = client.asked.lock();
assert_eq!(asked.len(), 3);
let questions = &asked[0]["toolCall"]["_meta"]["questions"];
assert_eq!(questions.as_array().unwrap().len(), 2);
assert_eq!(questions[0]["answerKey"], "first");
assert_eq!(questions[1]["answerKey"], "second");
assert_eq!(questions[0]["options"][0]["description"], "First");
acp.kill();
codex.kill();
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Scenario {
AuthFailure,
+9 -10
View File
@@ -733,19 +733,18 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
}
/// The ACP title of a command approval.
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String {
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
return format!("Allow network access to {host}");
}
// Approval payloads can describe a different command from the item's earlier summary.
let item = item.filter(|i| i.get("id") == params.get("itemId"));
let command = params
.get("command")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
match command {
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title"));
let title = from_item.or_else(|| {
let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"),
"commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) });
tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title"))
});
match title.filter(|t| !t.is_empty()) {
Some(t) => format!("Run {t}"),
None => "Run a command".to_owned(),
}
}
+1 -135
View File
@@ -81,64 +81,6 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
impl ProviderSpec {
pub(crate) fn login_hint(&self, program: &Path) -> String {
if !program.is_absolute() {
return self.login_hint.clone();
}
let search = ide_core::process::host_var_os("PATH").unwrap_or_default();
let builtin = builtin_spec(&self.id);
let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search));
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
}
pub(crate) fn sign_in_message(&self) -> String {
let name = &self.name;
let hint = self.login_hint(Path::new(&self.command));
format!("{name} needs you to sign in. {hint}")
}
/// Resolve once before connecting, so authentication guidance names that same install.
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
if self.driver != Driver::Acp {
self.command = crate::native::newest_install(&self.command);
}
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
self.command =
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
.to_string_lossy()
.into_owned();
self
}
}
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
let prefix = format!("Run `{}", builtin.command);
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
return spec.login_hint.clone();
};
let same_path = |a: &Path, b: &Path| {
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
};
if on_path.is_some_and(|path| same_path(path, program)) {
return spec.login_hint.clone();
}
let path = program.to_string_lossy();
let quoted = if windows {
format!("& '{}'", path.replace('\'', "''"))
} else {
format!("'{}'", path.replace('\'', "'\\''"))
};
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
let command = format!("{quoted}{args}");
match suffix {
" in a terminal." => format!("Run `{command}` in a terminal."),
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
_ => spec.login_hint.clone(),
}
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
@@ -177,14 +119,13 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
args: spec.args.clone(),
default_program: builtin.as_ref().map(|b| b.command.clone()),
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
login_hint: Some(spec.login_hint.clone()),
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
command_editable: reason.is_none(),
command_read_only_reason: reason,
installed: installed(&spec.command),
env: spec.env.iter().cloned().collect(),
config: vec![],
thought_levels: BTreeMap::new(),
commands: vec![],
source: spec.source.clone(),
}
@@ -371,81 +312,6 @@ fn is_executable(p: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
for spec in builtins() {
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
for windows in [false, true] {
let program =
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
let hint = render_login_hint(&spec, program, None, windows);
if separate {
assert_eq!(hint, spec.login_hint, "{}", spec.id);
} else {
let prefix =
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
assert_eq!(
render_login_hint(&spec, program, Some(program), windows),
spec.login_hint,
"a PATH-resolved command stays bare: {}",
spec.id
);
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
}
}
}
}
#[test]
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
let cursor = builtin_spec("cursor").unwrap();
assert_eq!(
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
);
assert_eq!(
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
);
let opencode = builtin_spec("opencode").unwrap();
assert_eq!(
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
"Run `'/registry/opencode' auth login` in a terminal."
);
let mut custom = cursor;
custom.id = "custom".into();
custom.login_hint = "Sign in with the agent's own command line first.".into();
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
}
#[test]
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
let dir = tempfile::tempdir().unwrap();
let mut cursor = builtin_spec("cursor").unwrap();
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
let program = dir.path().join(command);
std::fs::write(&program, "").unwrap();
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
let resolved = cursor.clone().resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), program);
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
cursor.command = format!("tools/{command}");
let resolved = cursor.resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
}
#[cfg(unix)]
#[test]
fn registry_login_hint_names_the_installed_executable() {
let mut spec = builtin_spec("cursor").unwrap();
spec.command = "/registry path/dist-package/cursor-agent".into();
assert_eq!(
describe(&spec).login_hint.as_deref(),
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
);
}
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
let dir = tempfile::tempdir().unwrap();
+10 -562
View File
@@ -3,55 +3,19 @@
use super::*;
use tokio::io::duplex;
#[test]
fn permission_uses_its_own_command_payload() {
let command = "python3 -c 'import pathlib\npathlib.Path(\"a.txt\").touch()'";
let mut request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
"title": "Run List .", "rawInput": {"command": command}}});
assert_eq!(permission_title(&request), format!("Run {command}"));
request["toolCall"]["title"] = json!("Allow network access to example.com");
assert_eq!(permission_title(&request), "Allow network access to example.com");
request["toolCall"].as_object_mut().unwrap().remove("title");
request["toolCall"].as_object_mut().unwrap().remove("rawInput");
request["toolCall"]["content"] = json!([{ "type": "content", "content": {"type": "text", "text": command}}]);
assert_eq!(permission_title(&request), format!("Run {command}"));
}
#[test]
fn permission_shows_the_script_inside_a_shell_wrapper() {
let wrapped = r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#;
let request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
"title": "Run python3", "rawInput": {"command": wrapped}}});
assert_eq!(permission_title(&request), "Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'");
}
/// Says "Hi", edits a file, asks to run a command and reports what it was allowed.
#[derive(Default)]
struct FakeAgent {
/// Every connection's agent side, kept open.
servers: Mutex<Vec<Peer>>,
calls: Mutex<Vec<String>>,
permission_replies: Mutex<Vec<Value>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
empty_config: AtomicBool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
}
impl FakeAgent {
fn options_for(&self, model: &str) -> Vec<Value> {
let mut options = vec![json!({"id": "model", "category": "model", "type": "select",
"currentValue": model,
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"},
{"value": "plain", "name": "Plain"}, {"value": "unknown", "name": "Unknown"}]})];
options
.extend(self.model_options.lock().get(model).cloned().unwrap_or_else(|| self.extra_options.lock().clone()));
options
}
/// The connection opened last: the agent restarted, or a fork's.
fn latest(&self) -> Peer {
self.servers.lock().last().cloned().expect("a connection")
@@ -123,11 +87,6 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
}
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
match method {
"initialize" => {
@@ -138,10 +97,10 @@ impl Handler for FakeAgent {
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
}
"session/new" => {
if self.empty_config.load(Ordering::Acquire) {
return Ok(json!({"sessionId": "s1"}));
}
let options = self.options_for("big");
let mut options = vec![json!({"id": "model", "category": "model", "type": "select",
"currentValue": "big",
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]})];
options.extend(self.extra_options.lock().iter().cloned());
Ok(json!({"sessionId": "s1",
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
"configOptions": options}))
@@ -162,9 +121,6 @@ impl Handler for FakeAgent {
}
"session/set_config_option" | "session/set_mode" => {
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
}
Ok(json!({}))
}
"session/prompt" => {
@@ -214,37 +170,9 @@ impl Handler for FakeAgent {
None,
))
.unwrap();
self.permission_replies.lock().push(answer.clone());
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("answered {chosen}"));
return Ok(json!({ "stopReason": "end_turn" }));
}
if text == "qwen ask" {
// Qwen Code's ACP dialect: the questions as `qwenQuestions`, Submit and
// Cancel as the options, the answers keyed by question index.
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session,
"toolCall": {"toolCallId": "q2", "title": "Ask user 2 questions", "kind": "think",
"_meta": {"toolName": "ask_user_question", "qwenInteractionKind": "user_question",
"qwenQuestions": [
{"question": "Which database?", "header": "Database", "options": [
{"label": "Postgres", "description": "Relational"},
{"label": "Redis", "description": "Key-value"}]},
{"question": "Which caches?", "header": "Caches", "multiSelect": true,
"options": [{"label": "Disk"}, {"label": "Memory"}]}]}},
"options": [{"optionId": "proceed_once", "name": "Submit", "kind": "allow_once"},
{"optionId": "cancel", "name": "Cancel", "kind": "reject_once"}]}),
None,
))
.unwrap();
let outcome = answer.pointer("/outcome/outcome").and_then(Value::as_str).unwrap_or("none");
let option = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
let answers = answer.get("answers").cloned().unwrap_or(Value::Null);
self.say(&session, &format!("answered {outcome} {option} {answers}"));
return Ok(json!({ "stopReason": "end_turn" }));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "wait" {
// Works until cancelled.
@@ -322,49 +250,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
)
}
#[tokio::test(flavor = "multi_thread")]
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
for auth_on_new in [true, false] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { auth_on_new, ..Default::default() });
let mgr = manager(tmp.path(), agent);
let program = tmp.path().join("Agent Tools").join("renamed-cursor.cmd");
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
program.to_string_lossy().into_owned();
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "cursor".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
if !auth_on_new {
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// A settings change must not redirect guidance for the already running process.
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
"different-cursor".into();
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
}
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
let entries = mgr.entries(&task.id, None).unwrap();
let error = entries
.iter()
.find_map(|e| match e {
AgentEntry::Error { message, .. } => Some(message),
_ => None,
})
.expect("authentication error");
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
assert!(error.contains(&*program.to_string_lossy()), "{error}");
assert!(error.ends_with(" login` in a terminal."), "{error}");
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
@@ -743,10 +628,9 @@ fn prompts_become_acp_content_blocks() {
}
#[tokio::test(flavor = "multi_thread")]
async fn claude_questions_keep_the_answer_object_as_the_option_id() {
async fn answered_questions_close_the_question_with_every_picked_label() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
@@ -764,22 +648,13 @@ async fn claude_questions_keep_the_answer_object_as_the_option_id() {
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, title, questions, .. } => {
assert_eq!(title, "AskUserQuestion");
assert_eq!(questions.len(), 2);
assert_eq!(questions[0].question, "Which layout?");
assert!(!questions[0].multi_select);
assert_eq!(questions[1].question, "Which checks?");
assert!(questions[1].multi_select);
Some(*id)
}
AgentEntry::Permission { id, .. } => Some(*id),
_ => None,
})
.unwrap();
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
mgr.respond(&task.id, question, Some(reply.clone())).unwrap();
mgr.respond(&task.id, question, Some(reply)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(*agent.permission_replies.lock(), [json!({"outcome": {"outcome": "selected", "optionId": reply}})]);
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
@@ -790,115 +665,6 @@ async fn claude_questions_keep_the_answer_object_as_the_option_id() {
);
}
/// Qwen Code asks its questions as `_meta.qwenQuestions` with Submit/Cancel options; the
/// card answers with `answers` keyed by question index beside the Submit option's id.
#[tokio::test(flavor = "multi_thread")]
async fn qwen_questions_answer_by_index_beside_the_submit_option() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("qwen ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let (id, title, questions) = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, title, questions, .. } => Some((*id, title.clone(), questions.clone())),
_ => None,
})
.unwrap();
assert_eq!(title, "Which database?", "the first question, not the request's own title");
assert_eq!(
questions,
vec![
AgentQuestion {
answer_key: None,
header: "Database".into(),
question: "Which database?".into(),
multi_select: false,
options: vec![
AgentQuestionOption { label: "Postgres".into(), description: "Relational".into(), preview: None },
AgentQuestionOption { label: "Redis".into(), description: "Key-value".into(), preview: None },
],
},
AgentQuestion {
answer_key: None,
header: "Caches".into(),
question: "Which caches?".into(),
multi_select: true,
options: vec![
AgentQuestionOption { label: "Disk".into(), description: String::new(), preview: None },
AgentQuestionOption { label: "Memory".into(), description: String::new(), preview: None },
],
},
]
);
let reply = json!({"Which database?": ["Postgres"], "Which caches?": ["Disk", "Memory"]}).to_string();
mgr.respond(&task.id, id, Some(reply)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
let expected = format!("answered selected proceed_once {}", json!({"0": "Postgres", "1": "Disk, Memory"}));
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == &expected)), "{entries:?}");
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Postgres; Disk, Memory")),
"{entries:?}"
);
}
/// A skipped Qwen question cancels the request: qwen-code reads the answers it expects
/// only beside a chosen option, and `reject` is none of the options it offered.
#[tokio::test(flavor = "multi_thread")]
async fn skipped_qwen_questions_cancel_the_request() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("qwen ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let id = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, questions, .. } if !questions.is_empty() => Some(*id),
_ => None,
})
.unwrap();
mgr.respond(&task.id, id, Some("reject".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "answered cancelled none null")),
"{entries:?}"
);
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")),
"{entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let tmp = tempfile::tempdir().unwrap();
@@ -1016,44 +782,6 @@ async fn real_claude_task_in_a_worktree() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn renamed_titles_survive_updates_forks_and_restart() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let handle = mgr.task(&task.id).unwrap();
assert!(mgr.rename(&task.id, " ").is_err());
let initial = handle.state.lock().task.title.clone();
for title in [initial, "My task".to_owned(), "名".repeat(205)] {
mgr.rename(&task.id, &title).unwrap();
handle.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Agent title"}));
assert_eq!(handle.state.lock().task.title, title.chars().take(200).collect::<String>());
}
mgr.rename(&task.id, " My task ").unwrap();
let copy = mgr.fork(&task.id, None).await.unwrap();
let copied = mgr.task(&copy.id).unwrap();
copied.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Copied agent title"}));
assert_eq!(copied.state.lock().task.title, "My task");
mgr.shutdown();
let restored = manager(tmp.path(), agent);
let handle = restored.task(&task.id).unwrap();
handle.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Resumed title"}));
assert_eq!(handle.state.lock().task.title, "My task");
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn forks_rollbacks_and_steering() {
let tmp = tempfile::tempdir().unwrap();
@@ -1102,33 +830,6 @@ async fn forks_rollbacks_and_steering() {
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// Dropping every turn starts a fresh session instead of rewinding the agent's history.
let handle = mgr.task(&task.id).unwrap();
for (parent, child) in [(None, "child"), (Some("child"), "grandchild")] {
handle.on_update(
parent,
&json!({"sessionUpdate": "subagent_update", "sessionId": child,
"state": {"state": "idle", "stopReason": "end_turn"}}),
);
}
handle.state.lock().conversation.main.entries.insert(0, AgentEntry::Agent { text: "Welcome".into() });
let rewinds = agent.calls.lock().iter().filter(|c| c.starts_with("_openidea/rollback")).count();
let first = mgr.rollback(&task.id, 0).await.unwrap().unwrap();
assert_eq!(first.text, "again");
assert!(mgr.entries(&task.id, None).unwrap().is_empty());
assert!(mgr.task(&task.id).unwrap().state.lock().session_id.is_none());
assert!(handle.state.lock().conversation.saved_subagents().is_empty());
assert!(handle.state.lock().task.usage.is_none());
assert_eq!(agent.calls.lock().iter().filter(|c| c.starts_with("_openidea/rollback")).count(), rewinds);
mgr.shutdown();
let restored = manager(tmp.path(), agent.clone());
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.prompt(&task.id, first, false).await.unwrap();
wait_for(&restored, &task.id, AgentStatus::Ready).await;
assert_eq!(turn_starts(&restored.entries(&task.id, None).unwrap()).len(), 1);
assert_eq!(agent.calls.lock().last().unwrap(), "session/prompt");
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
@@ -1162,22 +863,6 @@ async fn usage_limits_and_late_settings() {
assert!(agent.calls.lock().iter().any(|c| c == "session/set_config_option \"high\""));
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
for _ in 0..100 {
if mgr
.shared
.known
.lock()
.get("claude")
.and_then(|k| k.thought_levels.get("big"))
.and_then(|levels| levels.first())
.and_then(|o| o.current.as_deref())
== Some("high")
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["big"][0].current.as_deref(), Some("high"));
}
#[tokio::test(flavor = "multi_thread")]
@@ -1185,7 +870,7 @@ async fn remembered_settings_drop_per_model_choices_for_the_next_task() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
// The effort list depends on the model: what the last session ran means nothing once
// the user picks another model, so provider-wide defaults never include it.
// the user picks another model, so the New Task page shows the option without a choice.
*agent.extra_options.lock() = vec![json!({"id": "effort", "category": "thought_level",
"type": "select", "currentValue": "high",
"options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}]})];
@@ -1209,185 +894,6 @@ async fn remembered_settings_drop_per_model_choices_for_the_next_task() {
assert_eq!(model.current.as_deref(), Some("big"), "the model choice survives");
}
fn effort_options(levels: &[&str], current: &str) -> Vec<Value> {
vec![json!({"id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": levels.iter().map(|v| json!({"value": v, "name": v})).collect::<Vec<_>>()})]
}
#[tokio::test(flavor = "multi_thread")]
async fn thinking_levels_are_remembered_per_provider_and_model() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.model_options.lock() = HashMap::from([
("big".into(), effort_options(&["low", "high"], "high")),
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
("plain".into(), vec![]),
]);
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.set_config(&task.id, "model", "small".into()).await.unwrap();
mgr.set_config(&task.id, "effort", "medium".into()).await.unwrap();
mgr.set_config(&task.id, "model", "plain".into()).await.unwrap();
let p = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let big = &p.thought_levels["big"][0];
assert_eq!(big.current.as_deref(), Some("high"));
assert_eq!(big.values.iter().map(|v| v.value.as_str()).collect::<Vec<_>>(), ["low", "high"]);
let small = &p.thought_levels["small"][0];
assert_eq!(small.current.as_deref(), Some("medium"));
assert_eq!(small.values.iter().map(|v| v.value.as_str()).collect::<Vec<_>>(), ["minimal", "medium"]);
assert!(p.thought_levels["plain"].is_empty());
assert!(!p.thought_levels.contains_key("unknown"));
assert!(mgr.providers().iter().find(|p| p.id == "codex").unwrap().thought_levels.is_empty());
let saved: HashMap<String, Known> =
serde_json::from_slice(&std::fs::read(&mgr.shared.paths.models_file).unwrap()).unwrap();
assert_eq!(saved["claude"].thought_levels, p.thought_levels);
mgr.shutdown();
let restored = manager(tmp.path(), agent);
assert_eq!(restored.providers().into_iter().find(|p| p.id == "claude").unwrap().thought_levels, p.thought_levels);
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn upfront_effort_is_validated_after_changing_model_before_the_prompt() {
for (effort, applied) in [("medium", true), ("high", false)] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.model_options.lock() = HashMap::from([
("big".into(), effort_options(&["low", "high"], "low")),
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
]);
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
// Reversed on purpose: model must be set before validating effort.
config: vec![
AgentConfigChoice { id: "effort".into(), value: effort.into() },
AgentConfigChoice { id: "model".into(), value: "small".into() },
],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let calls = agent.calls.lock();
let model = calls.iter().position(|c| c == "session/set_config_option \"small\"").unwrap();
let effort_call = calls.iter().position(|c| c == &format!("session/set_config_option \"{effort}\""));
assert_eq!(effort_call.is_some(), applied);
if let Some(effort_call) = effort_call {
let prompt = calls.iter().position(|c| c == "session/prompt").unwrap();
assert!(model < effort_call && effort_call < prompt);
}
drop(calls);
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn unreported_model_switch_does_not_cache_the_previous_models_levels() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "high");
let mgr = manager(tmp.path(), agent);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.set_config(&task.id, "model", "small".into()).await.unwrap();
let p = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(!p.thought_levels.contains_key("small"));
assert_eq!(p.thought_levels["big"][0].current.as_deref(), Some("high"));
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn cached_effort_is_not_sent_without_real_session_options() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "low");
let mgr = manager(tmp.path(), agent.clone());
let new = |config| NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config,
isolation: Isolation::Local,
base: None,
};
let warm = mgr.create(new(vec![])).await.unwrap();
wait_for(&mgr, &warm.id, AgentStatus::Ready).await;
agent.calls.lock().clear();
agent.empty_config.store(true, Ordering::Release);
let task = mgr
.create(new(vec![
AgentConfigChoice { id: "model".into(), value: "big".into() },
AgentConfigChoice { id: "effort".into(), value: "high".into() },
]))
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c == "session/set_config_option \"high\""));
assert!(
mgr.task(&task.id).unwrap().state.lock().task.config.iter().any(|o| o.id == "effort"),
"remembered session settings remain visible until the agent refreshes them"
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn reported_levels_refresh_even_without_a_model_option() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "high");
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
for levels in [effort_options(&["minimal", "medium"], "medium"), vec![]] {
agent.update("s1", json!({"sessionUpdate": "config_option_update", "configOptions": levels}));
let expected = config::parse_options(&json!(levels));
for _ in 0..100 {
if mgr.shared.known.lock()["claude"].thought_levels["big"] == expected {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["big"], expected);
}
mgr.shutdown();
}
#[test]
fn retry_updates_become_a_countdown() {
let update =
@@ -1457,64 +963,6 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permissions() {
for prompt in ["ask", "qwen ask"] {
for bypass_at_start in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
// Cursor's client-side approval policy applies independently of the question dialect.
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: "cursor".into(),
config: if bypass_at_start {
vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }]
} else {
vec![]
},
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let id = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|entry| match entry {
AgentEntry::Permission { id, questions, chosen: None, .. } if questions.len() == 2 => Some(*id),
_ => None,
})
.unwrap();
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
assert!(mgr.task(&task.id).unwrap().permissions.lock().contains_key(&id), "{prompt}, {bypass_at_start}");
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::NeedsInput);
let reply = if prompt == "ask" {
json!({"Which layout?": ["Grid"], "Which checks?": ["Disk"]})
} else {
json!({"Which database?": ["Postgres"], "Which caches?": ["Memory"]})
};
mgr.respond(&task.id, id, Some(reply.to_string())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("Fix"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|entry| matches!(entry,
AgentEntry::Permission { questions, chosen: Some(name), .. }
if questions.is_empty() && name == "Allow")),
"{entries:?}"
);
assert!(entries.iter().any(|entry| matches!(entry, AgentEntry::Agent { text } if text == "chose yes")));
mgr.shutdown();
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_choice_applies_before_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
+1 -23
View File
@@ -290,8 +290,7 @@ impl Timeline {
/// and records when the turn ended (`done`) on the message that started it.
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
let mut changed = Vec::new();
// A steered message rides along in a running turn; the one that started it owns its end.
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { steered: false, .. }));
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
for (i, e) in self.entries.iter_mut().enumerate() {
match e {
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
@@ -469,27 +468,6 @@ mod tests {
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
}
#[test]
fn end_turn_records_on_the_message_that_started_the_turn() {
let user = |sent: f64, steered: bool| AgentEntry::User {
text: "q".into(),
attachments: vec![],
images: vec![],
sent_ms: Some(sent),
done_ms: None,
steered,
};
let mut t = Timeline::default();
t.push(user(1.0, false));
t.push(AgentEntry::Agent { text: "a".into() });
t.push(user(2.0, true));
// The steered message rides along in the turn: the reply's time belongs to the
// message that started it.
assert_eq!(t.end_turn(Some(5.0)), vec![0]);
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: Some(5.0), .. }));
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: None, .. }));
}
#[test]
fn long_output_keeps_its_tail() {
let s = tail("é".repeat(MAX_OUTPUT));
+3 -107
View File
@@ -67,11 +67,6 @@ impl BuildStep {
}
}
/// A CMake configure/build invocation, parsed as compiler output in the Build view.
pub fn cmake(program: &str, args: Vec<String>, cwd: PathBuf, env: BTreeMap<String, String>) -> Self {
Self { program: program.into(), args, cwd, env, format: Format::Gcc }
}
/// The command line as echoed in the console.
pub fn display(&self) -> String {
let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into());
@@ -158,7 +153,7 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod", "go.work"]) {
} else if has(dir, &["go.mod"]) {
Some(BuildSystem::Go)
} else if has(dir, &["tsconfig.json", "package.json"]) {
Some(BuildSystem::TypeScript)
@@ -232,7 +227,7 @@ pub fn plan(
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
BuildSystem::Cargo => cargo_steps(&dir, kind),
// Go builds the file's package (a module holds many).
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
};
@@ -379,33 +374,7 @@ fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> V
steps
}
fn go_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
if dir.join("go.work").is_file() {
let work_file = dunce::canonicalize(dir.join("go.work"))?;
let modules = ide_core::go::work_modules(&std::fs::read_to_string(dir.join("go.work"))?);
if modules.is_empty() {
return Err(IdeError::invalid("The Go workspace contains no modules"));
}
let mut steps = Vec::new();
for module in modules {
let module = dir.join(module);
if !module.join("go.mod").is_file() {
return Err(IdeError::not_found(format!("Go workspace module has no go.mod: {}", module.display())));
}
// Go compares workspace membership against cwd paths lexically.
let module = dunce::canonicalize(module)?;
let mut module_steps = go_module_steps(&module, kind);
for step in &mut module_steps {
step.env.insert("GOWORK".into(), work_file.to_string_lossy().into_owned());
}
steps.extend(module_steps);
}
return Ok(steps);
}
Ok(go_module_steps(dir, kind))
}
fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
let mut args = vec!["build"];
@@ -844,79 +813,6 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
#[cfg(test)]
mod tests {
#[test]
fn go_workspace_build_commands_compile_members_with_local_dependencies() {
let Some(go) = ide_core::process::find_executable("go", &ide_core::tool_path::search_path()) else {
return;
};
let tmp = tempfile::tempdir().unwrap();
let root = &tmp.path().join("workspace");
write(root, "go.work", "go 1.22\nuse (\n ./app\n ./lib\n ../external\n)\n");
write(root, "lib/go.mod", "module example.com/lib\ngo 1.22\n");
write(root, "lib/lib.go", "package lib\nfunc Value() int { return 1 }\n");
write(root, "app/go.mod", "module example.com/app\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "app/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
write(root, "../external/go.mod", "module example.com/external\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "../external/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
// std canonicalization also exercises verbatim paths on Windows.
for root in [root.to_path_buf(), std::fs::canonicalize(root).unwrap()] {
let plan = plan(&root, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
for step in plan.steps {
let result = std::process::Command::new(&go)
.args(&step.args)
.envs(&step.env)
.env("GOPROXY", "off")
.current_dir(&step.cwd)
.output()
.unwrap();
assert!(result.status.success(), "{step:?}: {}", String::from_utf8_lossy(&result.stderr));
}
}
}
#[test]
fn go_workspace_builds_every_use_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
write(&root, "go.work", "go 1.22\nuse (\n ./api\n \"./lib with spaces\"\n ../shared\n)\n");
for module in ["api", "lib with spaces", "../shared"] {
write(&root.join(module), "go.mod", "module example.com/p\ngo 1.22\n");
}
// Cover native paths, Windows verbatim paths and non-normalized module paths.
let canonical = std::fs::canonicalize(&root).unwrap();
for root in [&root, &canonical, &canonical.join("..").join("workspace")] {
for kind in [BuildKind::Build, BuildKind::Rebuild, BuildKind::Artifacts] {
let p = plan(root, &BuildModel::default(), kind, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Go);
assert_eq!(p.steps.len(), 3);
for (step, module) in p.steps.iter().zip(["api", "lib with spaces", "../shared"]) {
assert_eq!(step.cwd, dunce::canonicalize(root.join(module)).unwrap());
assert_eq!(
step.env["GOWORK"],
dunce::canonicalize(root.join("go.work")).unwrap().to_string_lossy()
);
let expected =
if kind == BuildKind::Rebuild { vec!["build", "-a", "./..."] } else { vec!["build", "./..."] };
assert_eq!(step.args, expected);
}
}
}
let member =
plan(&root, &BuildModel::default(), BuildKind::Build, Some(&root.join("api/pkg/a.go")), None).unwrap();
assert_eq!(member.steps.len(), 1);
assert_eq!(member.steps[0].cwd, root.join("api"));
assert_eq!(member.steps[0].args, ["build", "./pkg/..."]);
write(&root, "go.work", "use ./missing\n");
assert!(
plan(&root, &BuildModel::default(), BuildKind::Build, None, None)
.unwrap_err()
.to_string()
.contains("go.mod")
);
write(&root, "go.work", "go 1.22\n");
assert!(plan(&root, &BuildModel::default(), BuildKind::Build, None, None).is_err());
}
#[test]
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
-33
View File
@@ -166,16 +166,6 @@ impl Default for CompletionSettings {
pub struct AutoImportSettings {
pub add_unambiguous: bool,
pub optimize_on_the_fly: bool,
pub imports_on_paste: ImportsOnPaste,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ImportsOnPaste {
#[default]
Ask,
Always,
Never,
}
/// Editor › General › Code Folding.
@@ -261,7 +251,6 @@ pub struct SmartKeysSettings {
pub tab_out_of_pair: bool,
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub escape_on_paste: bool,
pub smart_indent: bool,
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
pub insert_doc_stub: bool,
@@ -279,7 +268,6 @@ impl Default for SmartKeysSettings {
surround_selection: true,
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
escape_on_paste: true,
smart_indent: true,
insert_doc_stub: true,
backspace_unindent: "autoindent".into(),
@@ -476,24 +464,3 @@ impl Default for EncodingSettings {
}
}
}
#[cfg(test)]
mod auto_import_tests {
use super::{AutoImportSettings, ImportsOnPaste};
#[test]
fn imports_on_paste_defaults_to_ask_for_existing_settings() {
let settings: AutoImportSettings = serde_json::from_str(r#"{"addUnambiguous":true}"#).unwrap();
assert!(settings.add_unambiguous);
assert_eq!(settings.imports_on_paste, ImportsOnPaste::Ask);
}
#[test]
fn imports_on_paste_modes_round_trip() {
for mode in ["ask", "always", "never"] {
let json = serde_json::json!({ "importsOnPaste": mode });
let settings: AutoImportSettings = serde_json::from_value(json).unwrap();
assert_eq!(serde_json::to_value(settings).unwrap()["importsOnPaste"], mode);
}
}
}
-2
View File
@@ -15,8 +15,6 @@ use std::sync::Arc;
pub enum IdeEvent {
/// Project window membership or selection changed.
WindowGroupsChanged,
/// A window entered or left full screen, itself or as a member of a full-screen window group.
WindowFullscreenChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
-124
View File
@@ -1,124 +0,0 @@
//! Go workspace module paths shared by project builds and run detection.
use std::path::PathBuf;
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
pub fn work_modules(text: &str) -> Vec<PathBuf> {
let mut tokens = Vec::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\n' | '(' | ')' => tokens.push(ch.to_string()),
ch if ch.is_whitespace() => {}
'/' if chars.peek() == Some(&'/') => {
for c in chars.by_ref() {
if c == '\n' {
tokens.push("\n".into());
break;
}
}
}
'"' | '`' => {
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
tokens.push(token);
}
_ => {
let mut token = ch.to_string();
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
token.push(chars.next().unwrap());
}
tokens.push(token);
}
}
}
let mut out = Vec::new();
let mut start = true;
let mut tokens = tokens.into_iter();
while let Some(token) = tokens.next() {
if token == "\n" {
start = true;
continue;
}
if start
&& token == "use"
&& let Some(path) = tokens.next()
{
if path == "(" {
let mut paths = Vec::new();
let mut closed = false;
for path in tokens.by_ref() {
if path == ")" {
closed = true;
break;
}
if path != "\n" && !path.is_empty() {
paths.push(PathBuf::from(path));
}
}
if closed {
out.extend(paths);
}
} else if path != "\n" && !path.is_empty() {
out.push(PathBuf::from(path));
}
}
start = false;
}
let mut seen = std::collections::HashSet::new();
out.retain(|path| seen.insert(path.clone()));
out
}
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
let mut bytes = Vec::new();
while let Some(ch) = chars.next() {
if ch == quote {
return String::from_utf8(bytes).ok();
}
if ch == '\\' && quote == '"' {
let escaped = chars.next()?;
let byte = match escaped {
'a' => 7,
'b' => 8,
'f' => 12,
'n' => 10,
'r' => 13,
't' => 9,
'v' => 11,
'\\' => b'\\',
'"' => b'"',
'x' => {
let digits: String = chars.by_ref().take(2).collect();
if digits.len() != 2 {
return None;
}
u8::from_str_radix(&digits, 16).ok()?
}
'u' | 'U' => {
let width = if escaped == 'u' { 4 } else { 8 };
let digits: String = chars.by_ref().take(width).collect();
if digits.len() != width {
return None;
}
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
bytes.extend_from_slice(ch.to_string().as_bytes());
continue;
}
'0'..='7' => {
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
if digits.len() != 3 {
return None;
}
u8::from_str_radix(&digits, 8).ok()?
}
_ => return None,
};
bytes.push(byte);
} else {
if ch == '\n' && quote == '"' {
return None;
}
bytes.extend_from_slice(ch.to_string().as_bytes());
}
}
None
}
-1
View File
@@ -7,7 +7,6 @@
pub mod editor_settings;
pub mod error;
pub mod event;
pub mod go;
pub mod ids;
pub mod ignored;
pub mod inspection_settings;
+5 -33
View File
@@ -299,11 +299,9 @@ fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
/// What to change in `vars` (an environment) so a child sees the host's environment rather
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` — or inside *any*
/// AppImage mount, since an in-app update restart inherits the previous mount's entries
/// alongside the new one — is dropped (a saved `<NAME>_ORIG` wins when present),
/// variables left empty are removed, and so are the runtime's own launch variables.
/// Nothing changes when `APPDIR` is not set.
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
let Some(appdir) =
@@ -311,12 +309,7 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
else {
return Vec::new();
};
let inside = |entry: &str| {
let own = entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
// AppImages extract to `/tmp/.mount_<name>`; an entry under another mount is
// another image's (e.g. the pre-update one a restart inherited), never the host's.
own || entry.starts_with("/tmp/.mount_")
};
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
let mut fixes = Vec::new();
for (key, value) in vars {
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
@@ -722,7 +715,7 @@ mod tests {
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
("HOME", "/home/me"),
("JAVA_HOME", "/opt/jdk"),
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
]);
assert_eq!(
got,
@@ -735,27 +728,6 @@ mod tests {
);
}
#[test]
fn entries_of_a_previous_updates_mount_are_dropped() {
// An in-app update restart inherits the old mount's entries: a child's curl
// must not load the old image's libraries (they can still be mounted).
let got = fixes(&[
("APPDIR", "/tmp/.mount_OpenIDjIlFFA"),
("LD_LIBRARY_PATH", "/tmp/.mount_OpenIDjIlFFA/usr/lib/:/tmp/.mount_OpenIDdaNGfM/usr/lib/:/usr/local/lib"),
("PATH", "/tmp/.mount_OpenIDjIlFFA/usr/bin/:/tmp/.mount_OpenIDdaNGfM/usr/bin/:/usr/bin"),
("GIO_MODULE_DIR", "/tmp/.mount_OpenIDdaNGfM/usr/lib/gio/modules"),
]);
assert_eq!(
got,
[
fix("APPDIR", None),
fix("LD_LIBRARY_PATH", Some("/usr/local/lib")),
fix("PATH", Some("/usr/bin")),
fix("GIO_MODULE_DIR", None),
]
);
}
#[test]
fn variables_left_empty_are_removed() {
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
-78
View File
@@ -1,6 +1,5 @@
//! Shared filesystem rules for discovering builds and resolving their owners.
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
pub const SEARCH_DEPTH: usize = 3;
@@ -36,23 +35,10 @@ pub fn discover_matching(
max_depth: usize,
stop_at_match: bool,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
discover_matching_cancellable(root, max_depth, stop_at_match, &crate::CancellationToken::new(), marked)
}
pub fn discover_matching_cancellable(
root: &Path,
max_depth: usize,
stop_at_match: bool,
cancel: &crate::CancellationToken,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
if cancel.is_cancelled() {
break;
}
if marked(&dir) {
found.push(dir.clone());
if stop_at_match {
@@ -80,70 +66,6 @@ pub fn discover_matching_cancellable(
found
}
/// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`.
pub fn expand_members(root: &Path, patterns: &[String], marker: &str) -> Vec<PathBuf> {
let mut dirs = Vec::new();
for pattern in patterns {
let pattern = pattern.trim().trim_start_matches("./").trim_end_matches('/');
if pattern.is_empty() || pattern.starts_with('!') {
continue;
}
let base = pattern.strip_suffix("/**").or_else(|| pattern.strip_suffix("/*"));
match base {
Some(base) if !base.contains('*') => {
dirs.extend(children(&root.join(base)).into_iter().filter(|d| d.join(marker).is_file()))
}
None if !pattern.contains('*') => {
let dir = root.join(pattern);
if dir.join(marker).is_file() {
dirs.push(dir);
}
}
_ => {}
}
}
let mut seen = HashSet::new();
dirs.retain(|d| seen.insert(d.clone()));
dirs
}
pub fn node_workspace_members(root: &Path) -> Vec<PathBuf> {
let package = std::fs::read(root.join("package.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.unwrap_or_default();
expand_members(root, &node_workspace_patterns(root, &package), "package.json")
}
fn node_workspace_patterns(root: &Path, pkg: &serde_json::Value) -> Vec<String> {
let mut patterns: Vec<String> = Vec::new();
let ws = pkg.get("workspaces");
let list = ws
.and_then(serde_json::Value::as_array)
.or_else(|| ws.and_then(|w| w.get("packages")).and_then(serde_json::Value::as_array));
if let Some(list) = list {
patterns.extend(list.iter().filter_map(serde_json::Value::as_str).map(str::to_owned));
}
if let Ok(yaml) = std::fs::read_to_string(root.join("pnpm-workspace.yaml")) {
let mut in_packages = false;
for line in yaml.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if !line.starts_with([' ', '\t', '-']) {
in_packages = trimmed.starts_with("packages:");
continue;
}
if in_packages && let Some(item) = trimmed.strip_prefix('-') {
let item = item.split(" #").next().unwrap_or_default().trim().trim_matches(['"', '\'']);
patterns.push(item.to_owned());
}
}
}
patterns
}
/// The closest package manager declaration or lockfile, shared by build and run.
pub fn package_manager(root: &Path) -> &'static str {
for dir in root.ancestors() {
-184
View File
@@ -850,8 +850,6 @@ pub struct JavaSettings {
/// Project JDK home; empty = per module, the installed JDK matching the
/// Java version it targets.
pub jdk: String,
/// Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default.
pub language_level: String,
/// JDK home the Java language server (jdtls) runs on, apart from the project
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
/// else the first installed one that is.
@@ -905,8 +903,6 @@ pub enum GradleDistribution {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// JDK home used to run Maven; empty = Project JDK, else the POM's Java version.
pub runner_jre: String,
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
@@ -957,98 +953,6 @@ impl Default for TypeScriptSettings {
}
}
/// Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DependencyScope {
#[default]
Compile,
Test,
Runtime,
Provided,
}
/// A project-level library (Project Structure › Libraries).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProjectLibrary {
pub name: String,
/// Class archives (`.jar`) or class directories.
pub classes: Vec<String>,
/// Source archives (`-sources.jar`, `.zip`) or source directories.
pub sources: Vec<String>,
/// Documentation archives (`-javadoc.jar`) or directories.
pub javadocs: Vec<String>,
pub scope: DependencyScope,
}
/// Dependency entry of a module (Project Structure › Modules › Dependencies).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ModuleDependency {
/// `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module).
pub kind: String,
pub target: String,
pub scope: DependencyScope,
pub exported: bool,
}
/// Per-module Project Structure overrides (SDK, language level, output paths, dependencies).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ModuleStructureSettings {
/// Module SDK home override; empty = inherit Project SDK.
pub jdk: String,
/// Module language level override; empty = inherit Project language level.
pub language_level: String,
/// Module compiler output directory override; empty = inherit Project compiler output.
pub compiler_output: String,
/// Module test compiler output directory override; empty = default.
pub test_compiler_output: String,
/// Additional module dependencies.
pub dependencies: Vec<ModuleDependency>,
}
/// Framework facet attached to a module (Project Structure › Facets).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FacetConfig {
/// `"web"` | `"spring"` | `"jpa"`.
pub kind: String,
/// Module relative path (`"."` for root module).
pub module: String,
/// Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`).
pub descriptor: String,
/// Web resource root directory for Web facets (e.g. `src/main/webapp`).
pub web_root: String,
}
/// Packaging type of a Project Structure artifact.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ArtifactKind {
#[default]
Jar,
War,
WarExploded,
}
/// Packaging artifact definition (Project Structure › Artifacts).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArtifactConfig {
pub name: String,
pub kind: ArtifactKind,
/// Module relative path (`"."` for root module).
pub module: String,
/// Output directory (relative to project root or absolute).
pub output_directory: String,
/// Main class for executable JAR artifacts.
pub main_class: String,
/// Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest).
pub include_libraries: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProjectStructureSettings {
@@ -1065,28 +969,9 @@ pub struct ProjectStructureSettings {
pub content_roots: Vec<String>,
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
pub exclude_files: String,
/// Project compiler output directory (relative to project root or absolute; empty = default).
pub compiler_output: String,
/// Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root).
pub modules: BTreeMap<String, ModuleStructureSettings>,
/// Project-level libraries (`JAR`s / class folders / source archives).
pub libraries: Vec<ProjectLibrary>,
/// Module framework facets (`web`, `spring`, `jpa`).
pub facets: Vec<FacetConfig>,
/// Packaging artifacts (`jar`, `war`, `warExploded`).
pub artifacts: Vec<ArtifactConfig>,
}
impl ProjectStructureSettings {
/// Overrides belong to the module's full path, never just its directory name.
pub fn module_settings(
&self,
root: &std::path::Path,
module: &std::path::Path,
) -> Option<&ModuleStructureSettings> {
self.modules.iter().find_map(|(key, settings)| (root.join(key) == module).then_some(settings))
}
/// The "Exclude files" name patterns.
pub fn exclude_file_patterns(&self) -> Vec<String> {
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
@@ -1267,81 +1152,12 @@ mod tests {
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
assert!(Settings::default().files.file_types.is_empty());
}
#[test]
fn project_structure_modules_libraries_facets_and_artifacts_round_trip() {
let old: Settings = serde_json::from_str(r#"{"projectStructure":{"sourceRoots":["src"]}}"#).unwrap();
assert!(old.project_structure.compiler_output.is_empty());
assert!(old.project_structure.modules.is_empty());
assert!(old.project_structure.libraries.is_empty());
assert!(old.project_structure.facets.is_empty());
assert!(old.project_structure.artifacts.is_empty());
assert!(old.languages.java.language_level.is_empty());
let mut settings = Settings::default();
settings.languages.java.language_level = "21".into();
settings.project_structure.compiler_output = "out/production".into();
settings.project_structure.modules.insert(
"backend".into(),
ModuleStructureSettings {
jdk: "/opt/jdk-21".into(),
language_level: "21".into(),
compiler_output: "backend/target/classes".into(),
test_compiler_output: "backend/target/test-classes".into(),
dependencies: vec![ModuleDependency {
kind: "library".into(),
target: "custom-lib".into(),
scope: DependencyScope::Compile,
exported: true,
}],
},
);
settings.project_structure.libraries.push(ProjectLibrary {
name: "custom-lib".into(),
classes: vec!["lib/custom.jar".into()],
sources: vec!["lib/custom-sources.jar".into()],
javadocs: Vec::new(),
scope: DependencyScope::Compile,
});
settings.project_structure.facets.push(FacetConfig {
kind: "web".into(),
module: "backend".into(),
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
web_root: "src/main/webapp".into(),
});
settings.project_structure.artifacts.push(ArtifactConfig {
name: "backend:war exploded".into(),
kind: ArtifactKind::WarExploded,
module: "backend".into(),
output_directory: "out/artifacts/backend_war_exploded".into(),
main_class: String::new(),
include_libraries: vec!["custom-lib".into()],
});
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["languages"]["java"]["languageLevel"], "21");
assert_eq!(value["projectStructure"]["compilerOutput"], "out/production");
assert_eq!(value["projectStructure"]["artifacts"][0]["kind"], "warExploded");
let restored: Settings = serde_json::from_value(value).unwrap();
assert_eq!(restored, settings);
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
#[test]
fn maven_runner_jre_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.runner_jre.is_empty());
assert!(MavenSettings::default().runner_jre.is_empty());
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
+84 -355
View File
@@ -12,17 +12,14 @@ use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use specta::Type;
use sqlx::{
Column, ColumnIndex, Connection, Decode, Executor, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode},
Column, ColumnIndex, Connection, Decode, Executor, MySql, Postgres, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
mysql::{MySqlConnectOptions, MySqlPool, MySqlPoolOptions, MySqlSslMode},
pool::PoolConnection,
postgres::{PgConnectOptions, PgConnection, PgSslMode},
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
};
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -50,135 +47,12 @@ pub struct QueryResult {
pub execution_ms: f64,
}
/// Session context belongs to the physical connection, including across pool checkouts.
#[derive(Debug, Clone)]
pub struct Context {
default: Option<String>,
current: Option<String>,
known: bool,
}
impl Context {
pub(crate) fn new(default: Option<String>) -> Self {
Self { current: default.clone(), default, known: true }
}
pub(crate) fn set_current(&mut self, current: Option<String>) {
self.current = current;
}
fn wanted(&self, driver: DbDriver, schema: Option<&str>) -> Option<String> {
schema
.filter(|s| !s.is_empty())
.and_then(|s| crate::sql::use_schema(driver, s))
.or_else(|| self.default.clone())
}
}
macro_rules! sqlx_pool {
($manager:ident, $handle:ident, $options:ty, $connection:ty, $driver:expr, $query:expr) => {
pub struct $manager {
options: $options,
init: Arc<Vec<String>>,
timeout: Duration,
}
pub struct $handle {
conn: Option<$connection>,
context: Context,
options: $options,
init: Arc<Vec<String>>,
timeout: Duration,
in_transaction: bool,
}
impl std::ops::Deref for $handle {
type Target = $connection;
fn deref(&self) -> &Self::Target {
self.conn.as_ref().expect("live connection")
}
}
impl std::ops::DerefMut for $handle {
fn deref_mut(&mut self) -> &mut Self::Target {
self.conn.as_mut().expect("live connection")
}
}
impl ManageConnection for $manager {
type Connection = $handle;
type Error = sqlx::Error;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
tokio::time::timeout(self.timeout, async {
let mut conn = <$connection>::connect_with(&self.options).await?;
let context_sql = |value: Option<String>| {
value.map(|v| {
if $driver == DbDriver::Postgres {
format!("SET search_path TO {v}")
} else {
crate::sql::use_schema($driver, &v).expect("switchable driver")
}
})
};
for sql in self.init.iter() {
sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut conn).await?;
}
let row = sqlx::raw_sql(sqlx::AssertSqlSafe($query.to_owned())).fetch_one(&mut conn).await?;
let context = Context::new(context_sql(row.try_get(0)?));
Ok($handle {
conn: Some(conn),
context,
options: self.options.clone(),
init: self.init.clone(),
timeout: self.timeout,
in_transaction: false,
})
})
.await
.map_err(|_| sqlx::Error::PoolTimedOut)?
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.ping().await
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.conn.is_none()
}
}
};
}
sqlx_pool!(PgManager, PgHandle, PgConnectOptions, PgConnection, DbDriver::Postgres, "SHOW search_path");
sqlx_pool!(MysqlManager, MysqlHandle, MySqlConnectOptions, MySqlConnection, DbDriver::Mysql, "SELECT DATABASE()");
pub struct ServerPool<M: ManageConnection> {
inner: Bb8Pool<M>,
closed: AtomicBool,
}
impl<M: ManageConnection> ServerPool<M> {
async fn get(&self) -> IdeResult<bb8::PooledConnection<'static, M>>
where
M::Error: std::fmt::Display,
{
if self.closed.load(Ordering::Relaxed) {
return Err(IdeError::new(ErrorKind::Io, "database connection is closed"));
}
self.inner.get_owned().await.map_err(|e| match e {
bb8::RunError::User(e) => IdeError::new(ErrorKind::Io, e.to_string()),
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
})
}
}
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
pub struct MssqlConnectionManager(tiberius::Config);
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
pub struct MssqlConnection(Option<MssqlClient>, Context);
pub struct MssqlConnection(Option<MssqlClient>);
impl std::ops::Deref for MssqlConnection {
type Target = MssqlClient;
@@ -202,7 +76,7 @@ impl ManageConnection for MssqlConnectionManager {
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
tcp.set_nodelay(true)?;
let mut client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
// Preserve the single redirect supported by the previous pool adapter.
Err(tiberius::error::Error::Routing { host, port }) => {
let mut config = self.0.clone();
@@ -214,11 +88,7 @@ impl ManageConnection for MssqlConnectionManager {
}
result => result?,
};
let row = client.simple_query("SELECT DB_NAME()").await?.into_row().await?;
let default = row
.and_then(|r| r.get::<&str, _>(0).map(str::to_owned))
.and_then(|db| crate::sql::use_schema(DbDriver::SqlServer, &db));
Ok(MssqlConnection(Some(client), Context::new(default)))
Ok(MssqlConnection(Some(client)))
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
@@ -233,13 +103,25 @@ impl ManageConnection for MssqlConnectionManager {
pub enum Pool {
Sqlite(SqlitePool),
Postgres(ServerPool<PgManager>),
Mysql(ServerPool<MysqlManager>),
Postgres(PgPool),
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OraclePool),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
macro_rules! with_pool {
($pool:expr, $p:ident => $body:expr) => {
match $pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
}
};
}
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
match &e {
sqlx::Error::Database(d) => {
@@ -556,19 +438,6 @@ macro_rules! backend_id {
}};
}
// Session control, scripts and PostgreSQL set_config can invalidate the tracked context.
fn context_may_change(sql: &str) -> bool {
let text = sql.trim().trim_end_matches(';');
text.contains(';')
|| text.to_ascii_lowercase().contains("set_config")
|| !text.split_whitespace().next().is_some_and(|s| {
matches!(
s.to_ascii_uppercase().as_str(),
"SELECT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP" | "TRUNCATE" | "SHOW" | "EXPLAIN"
)
})
}
fn looks_like_query(sql: &str) -> bool {
let first = sql
.lines()
@@ -666,13 +535,7 @@ impl Setup {
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
}
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
(_, "connect_timeout") => {
let seconds = number(&name)?;
if seconds == 0 {
return Err(IdeError::invalid("connect_timeout must be greater than zero"));
}
s.connect_timeout = Some(Duration::from_secs(seconds));
}
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
@@ -799,12 +662,14 @@ impl Pool {
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
let manager = PgManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Postgres(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
let pool = PgPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
Ok(Pool::Postgres(pool))
}
DbDriver::Mysql => {
let mut opts = MySqlConnectOptions::new()
@@ -846,12 +711,14 @@ impl Pool {
if !tz.is_empty() {
opts = opts.timezone(Some(tz.to_owned()));
}
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
let pool = MySqlPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
Ok(Pool::Mysql(pool))
}
DbDriver::SqlServer => {
use tiberius::Config;
@@ -890,9 +757,7 @@ impl Pool {
match self {
Pool::SqlServer(_) | Pool::Oracle(_) => {}
Pool::Clickhouse(p) => p.close(),
Pool::Sqlite(p) => p.close().await,
Pool::Postgres(p) => p.closed.store(true, Ordering::Relaxed),
Pool::Mysql(p) => p.closed.store(true, Ordering::Relaxed),
_ => with_pool!(self, p => p.close().await),
}
}
@@ -901,9 +766,7 @@ impl Pool {
Pool::SqlServer(_) => false,
Pool::Oracle(p) => p.is_closed(),
Pool::Clickhouse(p) => p.is_closed(),
Pool::Sqlite(p) => p.is_closed(),
Pool::Postgres(p) => p.closed.load(Ordering::Relaxed),
Pool::Mysql(p) => p.closed.load(Ordering::Relaxed),
_ => with_pool!(self, p => p.is_closed()),
}
}
@@ -912,9 +775,18 @@ impl Pool {
if let Pool::Clickhouse(p) = self {
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
}
let mut conn = Held::acquire(self).await?;
conn.apply_context(None).await?;
let fetched = conn.fetch_rows(sql, max).await;
if let Pool::Oracle(p) = self {
let mut conn = p.get().await?;
let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?;
return Ok(fetched.1);
}
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, 0, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
Ok(fetched?.1)
}
@@ -937,10 +809,7 @@ impl Pool {
return p.execute_from(sql, skip, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = match conn.apply_context(None).await {
Ok(()) => conn.execute_from(self, sql, skip, max_rows, cancel).await,
Err(e) => Err(e),
};
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -953,10 +822,8 @@ impl Pool {
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
match self {
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
Pool::SqlServer(_) => {
let mut held = Held::acquire(self).await?;
held.apply_context(None).await?;
let Held::SqlServer(mut conn) = held else { unreachable!() };
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
let result = async {
mssql_command(&mut conn, "BEGIN TRAN").await?;
let mut total = 0;
@@ -979,10 +846,8 @@ impl Pool {
}
result
}
Pool::Oracle(_) => {
let mut held = Held::acquire(self).await?;
held.apply_context(None).await?;
let Held::Oracle(mut conn) = held else { unreachable!() };
Pool::Oracle(p) => {
let mut conn = p.get().await?;
let result = crate::oracle::transaction(&mut conn, statements).await;
if result.is_err() {
// An open transaction must never return to the pool with the connection.
@@ -990,37 +855,21 @@ impl Pool {
}
result
}
_ => {
let mut conn = Held::acquire(self).await?;
conn.apply_context(None).await?;
macro_rules! transaction {
($c:expr) => {{
let mut tx = (&mut **$c).begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone()))
.execute(&mut *tx)
.await
.map_err(db_err)?;
let n = done.rows_affected();
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}};
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
total += n;
}
match &mut conn {
Held::Sqlite(c) => transaction!(c),
Held::Postgres(c) => transaction!(&mut ***c),
Held::Mysql(c) => transaction!(&mut ***c),
_ => unreachable!(),
}
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}),
}
}
}
@@ -1029,8 +878,8 @@ impl Pool {
/// manual-commit console across statements with its transaction open.
pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(Box<bb8::PooledConnection<'static, PgManager>>),
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OracleConnection),
@@ -1040,8 +889,8 @@ impl Held {
pub async fn acquire(pool: &Pool) -> IdeResult<Self> {
Ok(match pool {
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
Pool::Postgres(p) => Held::Postgres(Box::new(p.get().await?)),
Pool::Mysql(p) => Held::Mysql(Box::new(p.get().await?)),
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
Pool::Clickhouse(p) => {
if p.is_closed() {
@@ -1053,121 +902,8 @@ impl Held {
})
}
fn context(&mut self) -> Option<&mut Context> {
match self {
Held::Postgres(c) => Some(&mut c.context),
Held::Mysql(c) => Some(&mut c.context),
Held::SqlServer(c) => Some(&mut c.1),
Held::Oracle(c) => Some(c.context()),
_ => None,
}
}
fn invalidate_context(&mut self) {
if let Some(context) = self.context() {
context.known = false;
}
}
fn before_statement(&mut self, sql: &str) {
let control = sql.trim().trim_end_matches(';').trim().to_ascii_uppercase();
if let Held::Mysql(c) = self {
if matches!(control.as_str(), "BEGIN" | "START TRANSACTION") {
c.in_transaction = true;
return;
}
if matches!(control.as_str(), "COMMIT" | "ROLLBACK") {
return;
}
}
if context_may_change(sql) {
self.invalidate_context();
}
}
fn after_statement(&mut self, sql: &str) {
if let Held::Mysql(c) = self
&& matches!(sql.trim().trim_end_matches(';').trim().to_ascii_uppercase().as_str(), "COMMIT" | "ROLLBACK")
{
c.in_transaction = false;
}
}
pub async fn apply_context(&mut self, schema: Option<&str>) -> IdeResult<()> {
let driver = match self {
Held::Postgres(_) => DbDriver::Postgres,
Held::Mysql(_) => DbDriver::Mysql,
Held::SqlServer(_) => DbDriver::SqlServer,
Held::Oracle(_) => DbDriver::Oracle,
_ => return Ok(()),
};
let context = self.context().expect("switchable connection");
let wanted = context.wanted(driver, schema);
if context.known && context.current == wanted {
return Ok(());
}
context.known = false;
if let Some(sql) = &wanted {
self.raw_command(sql).await?;
} else {
// MySQL cannot USE an absent database. Re-open with the original options.
macro_rules! reopen {
($c:expr, $manager:ident) => {{
let manager = $manager { options: $c.options.clone(), init: $c.init.clone(), timeout: $c.timeout };
if $c.in_transaction {
return Err(IdeError::invalid("commit or roll back before clearing the selected database"));
}
let fresh = manager.connect().await.map_err(db_err)?;
if let Some(raw) = $c.conn.take() {
let _ = raw.close().await;
}
***$c = fresh;
}};
}
match self {
Held::Mysql(c) => reopen!(c, MysqlManager),
Held::Postgres(c) => reopen!(c, PgManager),
_ => unreachable!("server context has a default"),
}
}
let context = self.context().expect("switchable connection");
context.current = wanted;
context.known = true;
Ok(())
}
/// Restore before a manual session returns its physical connection to the pool.
pub async fn restore_default(&mut self) -> IdeResult<()> {
self.apply_context(None).await
}
async fn fetch_rows(&mut self, sql: &str, max: usize) -> IdeResult<Fetched> {
self.before_statement(sql);
let result = match self {
Held::Sqlite(c) => fetch!(&mut **c, sql, max, Cells::Text),
Held::Postgres(c) => fetch!(&mut ****c, sql, max, Cells::Text),
Held::Mysql(c) => fetch!(&mut ****c, sql, max, Cells::Text),
Held::SqlServer(c) => mssql_fetch(c, sql, 0, max).await,
Held::Oracle(c) => crate::oracle::run(c, sql, 0, max, Cells::Text, &CancellationToken::new()).await,
Held::Clickhouse(_) => unreachable!(),
};
if result.is_ok() {
self.after_statement(sql);
}
result
}
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
self.before_statement(sql);
let result = self.raw_command(sql).await;
if result.is_ok() {
self.after_statement(sql);
}
result
}
async fn raw_command(&mut self, sql: &str) -> IdeResult<()> {
match self {
Held::SqlServer(c) => mssql_command(c, sql).await,
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
@@ -1182,8 +918,8 @@ impl Held {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match other {
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
}
.map_err(db_err)
@@ -1196,15 +932,11 @@ impl Held {
pub async fn discard(self) {
match self {
Held::Sqlite(c) => drop(c.detach()),
Held::Postgres(mut c) => {
if let Some(raw) = c.conn.take() {
let _ = raw.close().await;
}
Held::Postgres(c) => {
let _ = c.detach().close().await;
}
Held::Mysql(mut c) => {
if let Some(raw) = c.conn.take() {
let _ = raw.close().await;
}
Held::Mysql(c) => {
let _ = c.detach().close().await;
}
Held::SqlServer(mut c) => {
c.0.take();
@@ -1239,7 +971,6 @@ impl Held {
if let Held::Clickhouse(c) = self {
return c.execute_from(sql, skip, max_rows, cancel).await;
}
self.before_statement(sql);
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
macro_rules! run {
@@ -1272,9 +1003,9 @@ impl Held {
let fetched: Fetched = match self {
Held::Sqlite(c) => run!(c, None::<&str>, |_: String| String::new()),
Held::Postgres(c) => {
run!(&mut ***c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
}
Held::Mysql(c) => run!(&mut ***c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
@@ -1285,7 +1016,6 @@ impl Held {
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
Held::Clickhouse(_) => unreachable!(),
};
self.after_statement(sql);
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
Ok(QueryResult {
@@ -1325,7 +1055,7 @@ mod tests {
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
assert!(manager.has_broken(&mut MssqlConnection(None, Context::new(None))));
assert!(manager.has_broken(&mut MssqlConnection(None)));
}
#[tokio::test]
@@ -1461,7 +1191,6 @@ mod tests {
assert_eq!(setup.charset.as_deref(), Some("utf8mb4"));
assert_eq!(setup.connect_timeout, Some(Duration::from_secs(5)));
assert!(Setup::of(&DataSource { properties: vec![prop("a; drop", "1")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "0")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
+11 -51
View File
@@ -315,7 +315,7 @@ fn keep_alive(pool: &Arc<Pool>, every: Duration) {
async fn end_session(session: &Session) {
let held = session.lock().await.take();
if let Some(mut conn) = held
&& (conn.command("ROLLBACK").await.is_err() || conn.restore_default().await.is_err())
&& conn.command("ROLLBACK").await.is_err()
{
conn.discard().await;
}
@@ -704,15 +704,8 @@ impl DbManager {
let introspection = self.source(id)?.introspection;
let system = introspection.show_system;
let pool = self.pool(id).await?;
if pool.driver() == DbDriver::SqlServer {
let first =
|rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT DB_NAME()", 1).await?).next().unwrap_or_default();
let names = first(pool.rows(sql::sqlserver_databases(), MAX_META_ROWS).await?).collect();
return Ok(Databases { current, names });
}
if pool.driver() != DbDriver::Postgres {
return Err(IdeError::invalid("this data source does not list databases"));
return Err(IdeError::invalid("only PostgreSQL data sources list databases"));
}
let first = |rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
@@ -1045,13 +1038,11 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
self.execute_from(id, statement, 0, max_rows, cancel, None).await
self.execute_from(id, statement, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
/// them: a later page of a result, where only that page is decoded and returned.
/// `schema` is a query console's active context: the connection that runs the
/// statement is put in it first, so unqualified names resolve there.
pub async fn execute_from(
&self,
id: &str,
@@ -1059,9 +1050,8 @@ impl DbManager {
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, skip, max_rows, cancel, schema).await;
let r = self.run(id, statement, skip, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1093,15 +1083,8 @@ impl DbManager {
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// The execution plan of `statement` (explained, not run), in the console's
/// active `schema` when it has one.
pub async fn explain(
&self,
id: &str,
statement: &str,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<Vec<PlanNode>> {
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
let driver = self.pool(id).await?.driver();
if driver == DbDriver::Oracle {
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
@@ -1119,16 +1102,14 @@ impl DbManager {
0,
MAX_META_ROWS,
cancel,
schema,
)
.await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel, None).await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?;
let nodes = plan::parse(driver, &result);
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel, None).await;
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
return nodes;
}
let result =
self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel, schema).await?;
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
@@ -1139,25 +1120,11 @@ impl DbManager {
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else {
if schema.is_none() {
return pool.execute_from(statement, skip, max_rows, cancel).await;
}
// Like `Pool::execute_from`, with the schema applied to the connection first.
let mut conn = Held::acquire(&pool).await?;
let r = match conn.apply_context(schema).await {
Ok(()) => conn.execute_from(&pool, statement, skip, max_rows, cancel).await,
Err(e) => Err(e),
};
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
}
return r;
return pool.execute_from(statement, skip, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
@@ -1165,12 +1132,10 @@ impl DbManager {
let mut held = session.lock().await;
if held.is_none() {
let mut conn = Held::acquire(&pool).await?;
conn.apply_context(schema).await?;
conn.command(conn::begin_statement(pool.driver())).await?;
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
conn.apply_context(schema).await?;
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
@@ -1206,12 +1171,7 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
match conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await;
+5 -15
View File
@@ -181,7 +181,7 @@ impl OraclePool {
.await
.map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))?
.map_err(oracle_err)?;
let _ = pool.add(opened);
let _ = pool.add(OracleHandle { conn: Some(opened) });
Ok(Self { state, pool: Arc::new(pool) })
}
@@ -208,7 +208,6 @@ fn pool_err(e: bb8::RunError<OracleError>) -> OracleError {
/// discarded (a cancelled statement leaves it in the blocking task).
pub struct OracleHandle {
conn: Option<oracle::Connection>,
context: crate::conn::Context,
}
pub struct OracleConnectionManager(Arc<State>);
@@ -223,7 +222,7 @@ impl ManageConnection for OracleConnectionManager {
.await
.map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?;
let conn = opened.map_err(OracleError::new)?;
Ok(conn)
Ok(OracleHandle { conn: Some(conn) })
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
@@ -272,10 +271,6 @@ pub struct OracleConnection {
}
impl OracleConnection {
pub(crate) fn context(&mut self) -> &mut crate::conn::Context {
&mut self.pooled.context
}
pub fn is_closed(&self) -> bool {
self.pooled.conn.is_none()
}
@@ -287,7 +282,7 @@ impl OracleConnection {
}
}
fn open(state: &State) -> oracle::Result<OracleHandle> {
fn open(state: &State) -> oracle::Result<oracle::Connection> {
let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone());
connector.driver_name("OpenIDEA");
if let Some(n) = state.statement_cache {
@@ -298,12 +293,7 @@ fn open(state: &State) -> oracle::Result<OracleHandle> {
for sql in &state.init {
statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?;
}
let user: String = conn.query_row_as("SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL", &[])?;
let default = crate::sql::use_schema(DbDriver::Oracle, &user);
let current: String = conn.query_row_as("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL", &[])?;
let mut context = crate::conn::Context::new(default);
context.set_current(crate::sql::use_schema(DbDriver::Oracle, &current));
Ok(OracleHandle { conn: Some(conn), context })
Ok(conn)
}
/// Runs `f` with the connection on the blocking pool. Cancelling drops the
@@ -568,7 +558,7 @@ mod tests {
)
.unwrap(),
));
let mut broken = OracleHandle { conn: None, context: crate::conn::Context::new(None) };
let mut broken = OracleHandle { conn: None };
assert!(manager.has_broken(&mut broken));
// A connection that cannot be opened reports the failure without panicking
// (a client-load error, or ORA-12541 when a client is available).
-31
View File
@@ -63,11 +63,6 @@ pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[
"XS$NULL",
];
/// Databases available to this login; offline/restoring databases cannot be selected.
pub fn sqlserver_databases() -> &'static str {
"SELECT name FROM sys.databases WHERE state = 0 AND HAS_DBACCESS(name) = 1 ORDER BY name"
}
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
match driver {
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
@@ -401,18 +396,6 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
}
}
/// The statement that makes unqualified names resolve in `schema` on the
/// connection that runs it (a query console's active schema; `None`: the driver
/// has no schema to switch — SQLite's `main`, ClickHouse's `USE` differs).
pub fn use_schema(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
DbDriver::Sqlite | DbDriver::Clickhouse => None,
DbDriver::Postgres => Some(format!("SET search_path TO {}", quote_ident(driver, schema))),
DbDriver::Mysql | DbDriver::SqlServer => Some(format!("USE {}", quote_ident(driver, schema))),
DbDriver::Oracle => Some(format!("ALTER SESSION SET CURRENT_SCHEMA = {}", quote_ident(driver, schema))),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSortKey")]
@@ -1011,20 +994,6 @@ mod tests {
assert!(object_ddl(DbDriver::Postgres, "s", K::Sequence, "q").unwrap().0.contains("sequencename = 'q'"));
}
#[test]
fn schema_switch_statement_per_dialect() {
assert_eq!(use_schema(DbDriver::Postgres, "app data"), Some("SET search_path TO \"app data\"".into()));
assert_eq!(use_schema(DbDriver::Mysql, "a`b"), Some("USE `a``b`".into()));
assert_eq!(use_schema(DbDriver::SqlServer, "app] data"), Some("USE [app]] data]".into()));
assert_eq!(use_schema(DbDriver::Postgres, "a\"b"), Some("SET search_path TO \"a\"\"b\"".into()));
assert_eq!(use_schema(DbDriver::Oracle, "a\"b"), Some("ALTER SESSION SET CURRENT_SCHEMA = \"a\"\"b\"".into()));
assert!(sqlserver_databases().contains("state = 0 AND HAS_DBACCESS(name) = 1"));
assert_eq!(use_schema(DbDriver::Oracle, "hr"), Some("ALTER SESSION SET CURRENT_SCHEMA = \"hr\"".into()));
// Single-schema (SQLite) and not-yet-supported drivers: no dropdown, no switch.
assert_eq!(use_schema(DbDriver::Sqlite, "main"), None);
assert_eq!(use_schema(DbDriver::Clickhouse, "default"), None);
}
#[test]
fn postgres_ddl() {
let cols = [
+1 -1
View File
@@ -103,7 +103,7 @@ async fn clickhouse_cloud_manager_catalogs_and_session_table() -> IdeResult<()>
"completion columns differ from catalog",
)?;
}
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel, None).await?;
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel).await?;
check(!plan.is_empty(), "query plan missing")?;
let value = r"path\O'Brien";
let result = db
-98
View File
@@ -85,101 +85,3 @@ async fn metadata_names_are_text_whatever_the_column_collation() -> IdeResult<()
let raw = manager(&tmp, "SET character_set_results = binary").await.unwrap();
assert_metadata_is_text(&raw).await
}
// A console's active schema: the connection that runs each autocommit statement
// is put in it first, so unqualified names resolve there (and not elsewhere).
// Its own schema names: tests of this file share one server but run in parallel.
#[tokio::test]
async fn active_schema_context_drives_the_executing_connection() -> IdeResult<()> {
const A: &str = "oi_ctx_meta";
const B: &str = "oi_ctx_data";
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp, "").await else { return Ok(()) };
let c = CancellationToken::new();
for s in [A, B] {
for stmt in [
format!("DROP DATABASE IF EXISTS `{s}`"),
format!("CREATE DATABASE `{s}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"),
format!("CREATE TABLE `{s}`.`用户` (id INT PRIMARY KEY, name VARCHAR(20))"),
format!("INSERT INTO `{s}`.`用户` VALUES (1, '{s}')"),
] {
db.execute("m", &stmt, 10, &c).await.unwrap();
}
}
// The source connects with no default database; the console's pick decides.
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(A)).await?;
assert_eq!(r.rows, vec![vec![Some(A.into())]]);
// Both schemas hold `用户`; the context resolves the unqualified name in its own,
// on every pooled connection the autocommit statements may land on.
for _ in 0..6 {
for (schema, name) in [(A, A), (B, B)] {
let r = db.execute_from("m", "SELECT name FROM 用户", 0, 1, &c, Some(schema)).await?;
assert_eq!(r.rows, vec![vec![Some(name.into())]], "of {schema}");
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some("")).await?;
assert_eq!(r.rows, vec![vec![None]]);
assert_eq!(db.completion("m").await?.default_schema, "");
}
}
// A statement without a selection returns to the source's absent default.
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, None).await?;
assert_eq!(r.rows, vec![vec![None]]);
// A manual-commit console switches schemas on its session connection.
db.set_manual_commit("m", true).await?;
// Starting a no-database manual session must keep its transaction open.
db.execute("m", &format!("INSERT INTO `{A}`.`用户` VALUES (2, 'rollback')"), 1, &c).await?;
db.finish_transaction("m", false).await?;
assert!(db.execute("m", &format!("SELECT name FROM `{A}`.`用户` WHERE id = 2"), 1, &c).await?.rows.is_empty());
let r = db.execute_from("m", "SELECT name FROM 用户", 0, 1, &c, Some(A)).await?;
assert_eq!(r.rows, vec![vec![Some(A.into())]]);
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(B)).await?;
assert_eq!(r.rows, vec![vec![Some(B.into())]]);
assert!(db.execute("m", "SELECT DATABASE()", 1, &c).await.is_err());
// The rejected clear keeps the transaction and selection intact.
assert_eq!(db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(B)).await?.rows, vec![vec![Some(B.into())]]);
db.finish_transaction("m", true).await?;
for commit in [false, true] {
db.execute_from("m", "SELECT 1", 0, 1, &c, Some(A)).await?;
db.finish_transaction("m", commit).await?;
let r = db.execute("m", "SELECT DATABASE()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![None]]);
}
db.execute_from("m", "SELECT 1", 0, 1, &c, Some(A)).await?;
db.set_manual_commit("m", false).await?;
db.execute("m", &format!("USE `{B}`"), 1, &c).await?;
let r = db.execute("m", "SELECT DATABASE()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![None]]);
// An unknown schema fails the statement instead of running it somewhere else.
let error = db.execute_from("m", "SELECT 1", 0, 1, &c, Some("oi_missing")).await.unwrap_err();
assert!(error.message.contains("oi_missing"), "{}", error.message);
Ok(())
}
#[tokio::test]
async fn a_reused_physical_connection_restores_the_configured_database() -> IdeResult<()> {
use ide_db::{Held, Pool};
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Mysql,
host,
port: Some(port),
user,
database: "mysql".into(),
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
let c = CancellationToken::new();
let _other = [Held::acquire(&pool).await?, Held::acquire(&pool).await?, Held::acquire(&pool).await?];
let mut conn = Held::acquire(&pool).await?;
let backend = conn.execute(&pool, "SELECT CONNECTION_ID()", 1, &c).await?.rows;
for schema in ["information_schema", "mysql", "information_schema"] {
conn.apply_context(Some(schema)).await?;
assert_eq!(conn.execute(&pool, "SELECT DATABASE()", 1, &c).await?.rows, vec![vec![Some(schema.into())]]);
}
drop(conn);
assert_eq!(pool.rows("SELECT DATABASE()", 1).await?, vec![vec![Some("mysql".into())]]);
assert_eq!(pool.execute("SELECT CONNECTION_ID()", 1, &c).await?.rows, backend);
Ok(())
}
-135
View File
@@ -1,135 +0,0 @@
//! The active schema context against a real PostgreSQL server, skipped unless
//! `OPENIDEA_TEST_POSTGRES=host:port:user:password` names a throwaway server.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{DataSource, DbDriver, DbManager};
fn server() -> Option<(String, u16, String, String)> {
let v = std::env::var("OPENIDEA_TEST_POSTGRES").ok()?;
let mut it = v.splitn(4, ':');
Some((it.next()?.into(), it.next()?.parse().ok()?, it.next()?.into(), it.next().unwrap_or("").into()))
}
async fn manager(tmp: &tempfile::TempDir) -> Option<DbManager> {
let (host, port, user, password) = server()?;
let root = tmp.path().to_path_buf();
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "p".into(),
name: "postgres".into(),
driver: DbDriver::Postgres,
host,
port: Some(port),
user,
..Default::default()
}])
.await
.unwrap();
db.set_password("p", Some(password), false).await.unwrap();
Some(db)
}
async fn create_schemas(db: &DbManager) {
let c = CancellationToken::new();
for (schema, tag) in [("oi_a", "from a"), ("oi_b", "from b")] {
for stmt in [
format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"),
format!("CREATE SCHEMA \"{schema}\""),
format!("CREATE TABLE \"{schema}\".items (id INT PRIMARY KEY, tag TEXT)"),
format!("INSERT INTO \"{schema}\".items VALUES (1, '{tag}')"),
] {
db.execute("p", &stmt, 10, &c).await.unwrap();
}
}
}
#[tokio::test]
async fn active_schema_context_drives_the_executing_connection() -> IdeResult<()> {
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp).await else { return Ok(()) };
create_schemas(&db).await;
let c = CancellationToken::new();
// The connection default stays untouched before any console picks a schema.
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert!(r.rows[0][0].as_deref().unwrap_or("").contains("public"), "{:?}", r.rows);
// Both schemas hold `items`; the context resolves the unqualified name in its own,
// on every pooled connection the autocommit statements may land on.
for _ in 0..6 {
for (schema, tag) in [("oi_a", "from a"), ("oi_b", "from b")] {
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some(schema)).await?;
assert_eq!(r.rows, vec![vec![Some(tag.into())]], "of {schema}");
let r = db.execute_from("p", "SHOW search_path", 0, 1, &c, Some("")).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
assert_eq!(db.completion("p").await?.default_schema, "public");
}
}
// A manual-commit console switches schemas on its session connection.
db.set_manual_commit("p", true).await?;
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_a")).await?;
assert_eq!(r.rows, vec![vec![Some("from a".into())]]);
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_b")).await?;
assert_eq!(r.rows, vec![vec![Some("from b".into())]]);
db.finish_transaction("p", true).await?;
for commit in [false, true] {
db.execute_from("p", "SELECT 1", 0, 1, &c, Some("oi_a")).await?;
db.finish_transaction("p", commit).await?;
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
}
db.set_manual_commit("p", false).await?;
db.execute("p", "SET search_path TO oi_b", 1, &c).await?;
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
// An unknown schema never resolves the statement's tables (`SET search_path`
// itself does not validate names; resolution against it does).
let error = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_missing")).await.unwrap_err();
assert!(error.message.to_lowercase().contains("does not exist"), "{}", error.message);
Ok(())
}
#[tokio::test]
async fn a_reused_physical_connection_restores_the_full_default_path() -> IdeResult<()> {
use ide_db::{DbOptions, Held, Pool};
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Postgres,
host,
port: Some(port),
user,
options: DbOptions { default_schema: "public, pg_catalog".into(), ..Default::default() },
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
let c = CancellationToken::new();
// Occupy the other three slots: every operation below must reuse the fourth.
let _other = [Held::acquire(&pool).await?, Held::acquire(&pool).await?, Held::acquire(&pool).await?];
let mut conn = Held::acquire(&pool).await?;
let backend = conn.execute(&pool, "SELECT pg_backend_pid()", 1, &c).await?.rows;
for schema in ["pg_catalog", "public", "pg_catalog"] {
conn.apply_context(Some(schema)).await?;
let r = conn.execute(&pool, "SELECT current_schema()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some(schema.into())]]);
}
conn.apply_context(Some("public")).await?;
conn.execute(&pool, "SELECT set_config('search_path', 'pg_catalog', false)", 1, &c).await?;
conn.apply_context(Some("public")).await?;
assert_eq!(conn.execute(&pool, "SELECT current_schema()", 1, &c).await?.rows, vec![vec![Some("public".into())]]);
drop(conn);
assert_eq!(pool.rows("SHOW search_path", 1).await?, vec![vec![Some("public, pg_catalog".into())]]);
assert_eq!(pool.execute("SELECT pg_backend_pid()", 1, &c).await?.rows, backend);
let mut conn = Held::acquire(&pool).await?;
conn.command("BEGIN").await?;
conn.apply_context(Some("pg_catalog")).await?;
conn.command("ROLLBACK").await?;
conn.restore_default().await?;
assert_eq!(
conn.execute(&pool, "SHOW search_path", 1, &c).await?.rows,
vec![vec![Some("public, pg_catalog".into())]]
);
Ok(())
}
+5 -5
View File
@@ -132,16 +132,16 @@ async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
let sql = "SELECT id FROM users ORDER BY id";
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
let second = db.execute_from("s1", sql, 1, 1, &c, None).await?;
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
assert_eq!(ids(&second), vec!["2"]);
assert!(second.has_more);
assert_eq!(second.columns.len(), 1);
let last = db.execute_from("s1", sql, 2, 5, &c, None).await?;
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
assert_eq!(ids(&last), vec!["3"]);
assert!(!last.has_more);
let past = db.execute_from("s1", sql, 9, 5, &c, None).await?;
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
assert!(past.rows.is_empty() && !past.has_more);
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
Ok(())
@@ -276,11 +276,11 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> {
async fn explains_queries_without_running_them() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c, None).await?;
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c).await?;
let ops: Vec<_> = plan.iter().map(|n| n.operation.as_str()).collect();
assert_eq!(ops, vec!["Index Scan", "Order By"]);
assert!(plan[0].params.starts_with("table: users;"), "{}", plan[0].params);
db.explain("s1", "DELETE FROM users", &c, None).await?;
db.explain("s1", "DELETE FROM users", &c).await?;
assert_eq!(count(&db).await, "3");
Ok(())
}
+7 -49
View File
@@ -443,7 +443,7 @@ pub fn compose_op(
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context).
/// up (`DOCKER_HOST`, else the context's endpoint).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
@@ -451,31 +451,17 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => default_daemon_url(
ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()),
ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()),
|name| context_host(&DockerEndpoint::Default, name),
)?,
DockerEndpoint::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
fn default_daemon_url(
context: Option<&str>,
host: Option<&str>,
lookup: impl FnOnce(Option<&str>) -> IdeResult<String>,
) -> IdeResult<String> {
let context = context.filter(|v| !v.is_empty());
if context.is_some() {
return lookup(context);
}
match host.filter(|v| !v.is_empty()) {
Some(host) => Ok(host.to_owned()),
None => lookup(None),
}
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
@@ -520,34 +506,6 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
mod local_tests {
use super::*;
#[test]
fn the_selected_context_overrides_docker_host() {
let local = "unix:///var/run/docker.sock";
let remote = "ssh://me@build";
for host in [None, Some(""), Some(local)] {
let url = default_daemon_url(Some("build"), host, |name| {
assert_eq!(name, Some("build"));
Ok(remote.into())
})
.unwrap();
assert!(!daemon_url_is_local(&url));
}
for context in [None, Some("")] {
assert_eq!(
default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(),
local
);
assert_eq!(
default_daemon_url(context, None, |name| {
assert_eq!(name, None);
Ok(remote.into())
})
.unwrap(),
remote
);
}
}
#[test]
fn local_daemon_urls() {
for url in [
+13 -28
View File
@@ -18,15 +18,13 @@ pub enum Capability {
EditorRead,
/// Edit the focused editor (versioned edits).
EditorWrite,
/// Create and replace files inside the project root.
ProjectWrite,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
Storage,
/// The plugin's own secrets (kept in the client's OS keychain).
Secrets,
/// Workbench events (files, focus, VCS), not keystrokes.
/// Workbench events (file opened, saved, focus…), not keystrokes.
Events,
/// Keystrokes and editor input events.
InputEvents,
@@ -38,8 +36,6 @@ pub enum Capability {
Process(String),
/// Read files outside the project under one path.
FsRead(String),
/// Write files outside the project under one path.
FsWrite(String),
}
/// How a grant is presented (plan 4.2).
@@ -58,14 +54,10 @@ impl Capability {
pub fn risk(&self) -> Risk {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite
| Self::ProjectWrite
| Self::VcsWrite
| Self::Net(_)
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
Risk::Medium
}
Self::Process(_) | Self::FsRead(_) => Risk::High,
}
}
@@ -81,12 +73,11 @@ impl Capability {
Self::ProjectRead => "Read files in the open project".into(),
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::ProjectWrite => "Create and change files in the open project".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe editor, filesystem and version control events".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
@@ -94,7 +85,6 @@ impl Capability {
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
}
}
@@ -102,7 +92,7 @@ impl Capability {
pub fn covers(&self, wanted: &Capability) -> bool {
match (self, wanted) {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
(Self::FsRead(base), Self::FsRead(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
_ => self == wanted,
@@ -126,7 +116,6 @@ impl fmt::Display for Capability {
Self::ProjectRead => f.write_str("project.read"),
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::ProjectWrite => f.write_str("project.write"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
@@ -137,7 +126,6 @@ impl fmt::Display for Capability {
Self::Net(h) => write!(f, "net:{h}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
}
}
}
@@ -170,11 +158,11 @@ impl FromStr for Capability {
}
Ok(Self::Process(arg.to_owned()))
}
"fs.read" | "fs.write" => {
"fs.read" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
}
Ok(if kind == "fs.read" { Self::FsRead(arg.to_owned()) } else { Self::FsWrite(arg.to_owned()) })
Ok(Self::FsRead(arg.to_owned()))
}
_ => Err(format!("unknown capability {s:?}")),
};
@@ -183,7 +171,6 @@ impl FromStr for Capability {
"project.read" | "project-read" => Self::ProjectRead,
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"project.write" => Self::ProjectWrite,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
@@ -237,7 +224,6 @@ mod tests {
for s in [
"project.read",
"editor.write",
"project.write",
"vcs.write",
"storage",
"secrets",
@@ -250,12 +236,12 @@ mod tests {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
// Absolute paths are the platform's.
let (git, etc, out) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows", r"fs.write:C:\out")
let (git, etc) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
} else {
("process:/usr/bin/git", "fs.read:/etc", "fs.write:/out")
("process:/usr/bin/git", "fs.read:/etc")
};
for s in [git, etc, out] {
for s in [git, etc] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
@@ -263,7 +249,6 @@ mod tests {
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("fs.write:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
}
+3 -52
View File
@@ -308,13 +308,6 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
/// Resolves a `process:` program to an absolute path once, at grant time:
/// absolute names are taken as is, others are looked up in `path_var`.
/// The directory is canonicalized but the file name kept as found: `cargo`,
/// `rustc` and friends are often links to a multicall shim (rustup, asdf,
/// mise) that picks the tool by the name it was invoked under — resolving the
/// link itself would start the shim with the tool's arguments. The grant
/// still pins the target's content: a hash read through a link reads the file
/// it points to, so relinking or replacing either still fails the
/// approved-file check.
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
let candidate = Path::new(name);
if candidate.is_absolute() {
@@ -332,21 +325,15 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
for ext in &exts {
let p = dir.join(format!("{name}{ext}"));
if is_executable(&p) {
return Ok(canonical_dir(&p));
return Ok(dunce_canonical(&p));
}
}
}
Err(format!("{name} was not found on PATH"))
}
/// `p` with its parent canonicalized but the file name as found: PATH
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
/// their real directory, while a linked program (`cargo` → rustup) keeps
/// its link name so the shim still dispatches on argv[0].
fn canonical_dir(p: &Path) -> PathBuf {
let Some(dir) = p.parent() else { return p.to_path_buf() };
let Some(name) = p.file_name() else { return p.to_path_buf() };
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
fn dunce_canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -487,42 +474,6 @@ mod tests {
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
/// A program found as a link stays the link: multicall shims (rustup,
/// asdf, mise) pick the tool by the name they were invoked under, so
/// `Command::new` must see the link. The hash still covers the target —
/// reading through the link reads it — so replacing either still trips
/// the approved-file check.
#[cfg(unix)]
#[test]
fn linked_programs_resolve_to_the_link() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
let real_dir = tmp.path().join("real");
std::fs::create_dir(&bin_dir).unwrap();
std::fs::create_dir(&real_dir).unwrap();
let target = real_dir.join("shim");
std::fs::write(&target, "#!/bin/sh\necho v1\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
std::os::unix::fs::symlink(&target, bin_dir.join("cargo")).unwrap();
let path_var = std::env::join_paths([bin_dir.clone()]).unwrap();
let resolved = resolve_program("cargo", Some(&path_var)).unwrap();
// Directory canonicalized, file name kept — still the link.
let want = std::fs::canonicalize(&bin_dir).unwrap().join("cargo");
assert_eq!(resolved, want);
assert!(resolved.symlink_metadata().unwrap().file_type().is_symlink());
let mut l = Ledger::in_memory();
let r = req("1.0.0", "h", &["process:cargo"], &[]);
let cap: Capability = "process:cargo".parse().unwrap();
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
std::fs::write(&target, "#!/bin/sh\necho evil\n").unwrap();
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
#[test]
fn hashes_and_grouping() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -31,6 +31,6 @@ pub use manifest::{
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+1 -37
View File
@@ -144,8 +144,7 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] =
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -316,11 +315,6 @@ impl Manifest {
self.needs_api("`icon`", 2)?;
check_relative(icon, "icon")?;
}
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
return Err("`icon-dark` needs `icon`".into());
@@ -332,9 +326,6 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
if !seen.insert(event) {
return Err(format!("duplicate activation event {event}"));
}
@@ -499,10 +490,6 @@ mod tests {
fn event_activation() {
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
assert_eq!(
ActivationEvent::parse("on-event:files-changed"),
Ok(ActivationEvent::Event("files-changed".into()))
);
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
@@ -596,29 +583,6 @@ mod tests {
const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n";
#[test]
fn files_changed_activation_needs_api_1_3() {
let activation = "wasm = \"plugin.wasm\"\nactivation = [\"on-event:files-changed\"]\n";
for engine in ["^1.0", "^1.2", ">=1.1, <2"] {
let base = BASE.replace("^1.2", engine);
assert!(err(&format!("{base}{activation}")).contains("new in plugin API 1.3"), "{engine}");
}
for engine in ["^1.3", ">=1.3, <2"] {
Manifest::parse(&format!("{}{activation}", BASE.replace("^1.2", engine))).unwrap();
}
Manifest::parse(&format!("{BASE}{}", activation.replace("files-changed", "file-saved"))).unwrap();
}
#[test]
fn write_capabilities_need_api_1_3() {
for cap in ["project.write", "fs.write:~/tool"] {
let caps = format!("[capabilities]\noptional = [\"{cap}\"]\n");
assert!(err(&format!("{BASE}{caps}")).contains("new in plugin API 1.3"), "{cap}");
let m = Manifest::parse(&format!("{}{caps}", BASE.replace("^1.2", "^1.3"))).unwrap();
assert_eq!(m.capabilities.optional[0].to_string(), cap);
}
}
#[test]
fn parses_icons() {
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
-19
View File
@@ -9,11 +9,6 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
/// The point behind a package's `locales/<tag>.json` catalogs: it has no
/// `[[contributes.locales]]` table, the directory declares it.
pub const LOCALES_POINT: &str = "locales";
pub const LOCALES_DIR: &str = "locales";
/// A contribution as the plugin details page lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -132,20 +127,6 @@ impl ExtensionRegistry {
set.items.push((point.id(), prepared));
}
}
if let Some(point) = self.point(LOCALES_POINT)
&& !pkg.manifest.contributes.contains_key(LOCALES_POINT)
&& pkg.dir.join(LOCALES_DIR).is_dir()
{
let prepared = point
.prepare(pkg, toml::Value::Table(Default::default()))
.map_err(|e| format!("{LOCALES_DIR}: {}", strip_prefix(&e, LOCALES_POINT)))?;
for f in &prepared.files {
if !set.files.contains(f) {
set.files.push(f.clone());
}
}
set.items.push((point.id(), prepared));
}
Ok(set)
}
+1 -1
View File
@@ -49,7 +49,7 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
serde_json.workspace = true
toml.workspace = true
specta.workspace = true
specta-typescript.workspace = true
+1 -20
View File
@@ -15,22 +15,8 @@ fn main() {
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
return;
}
if args.first().is_some_and(|a| a == "--connect") {
// One forwarded connection (see `ide_host::relay`): stdout carries its bytes.
let code = match ide_host::relay::parse_args(&args[1..]).and_then(|(host, port)| {
ide_host::relay::relay(&host, port, std::io::stdin(), std::io::stdout().lock())
.map_err(|e| format!("cannot connect to {host}:{port}: {e}"))
}) {
Ok(()) => 0,
Err(e) => {
eprintln!("{e}");
1
}
};
std::process::exit(code);
}
if !args.iter().any(|a| a == "--stdio") {
eprintln!("usage: openidea-server --stdio | --connect <host> <port> | --version");
eprintln!("usage: openidea-server --stdio | --version");
std::process::exit(2);
}
// stdout carries the protocol: logs go to stderr.
@@ -92,11 +78,6 @@ async fn run(base: PathBuf) -> i32 {
None
}
};
if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV)
&& let Err(e) = host.trust.inherit(std::path::Path::new(&root))
{
tracing::warn!("cannot trust {}: {e}", root.to_string_lossy());
}
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
let mut dispatcher = Dispatcher::standard();
if let Some(plugins) = &plugins {
+1 -5
View File
@@ -22,10 +22,6 @@ use std::time::Duration;
type Changes = Vec<(OsString, Option<OsString>)>;
/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands
/// (`devcontainer up`): the server then doesn't run `postStartCommand` again.
pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE";
/// What Reload Environment changed.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -76,7 +72,7 @@ impl Environment {
return None;
}
let config = self.load_project(root, sink);
let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none());
let post_start = config.and_then(|c| c.post_start);
let first = self.state.lock().post_started.insert(root.to_path_buf());
post_start.filter(|_| first)
}
-7
View File
@@ -14,7 +14,6 @@ mod phpstan;
pub mod plugins;
mod project;
mod proxy;
pub mod relay;
mod requests;
pub mod rpc;
pub mod shell_env;
@@ -224,8 +223,6 @@ impl Host {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
@@ -376,10 +373,6 @@ impl Host {
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in &projects {
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
}
for project in &projects {
project.plugin_diagnostics.close();
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
-5
View File
@@ -257,10 +257,6 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
host.project(window)?.agents.unarchive(&task)
}
pub fn rename(host: &Host, window: &str, task: String, title: String) -> IdeResult<()> {
host.project(window)?.agents.rename(&task, &title)
}
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.delete(&task, remove_worktree).await
@@ -313,7 +309,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
});
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
d.add("agent_rename", |c| async move { rename(&c.host, &c.window, c.arg("task")?, c.arg("title")?) });
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
d.add(
"agent_delete",
+12 -93
View File
@@ -17,6 +17,14 @@ fn join_err(e: tokio::task::JoinError) -> IdeError {
IdeError::internal(e.to_string())
}
/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none
/// when the project names no version).
fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> BTreeMap<String, String> {
let jdks = ide_sdk::java::installed_jdks();
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
}
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
/// the Maven home and options from Settings › Maven.
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
@@ -32,7 +40,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let mvn = ide_sdk::apply::maven_command(root, settings);
let options = ide_sdk::apply::maven_options(root, settings);
for p in &mut m.projects {
p.env = ide_sdk::apply::maven_build_env(root, settings, p.java_version);
p.env = java_env(jdk.as_ref(), p.java_version);
if let Some(mvn) = &mvn {
p.command = mvn.to_string_lossy().into_owned();
}
@@ -56,11 +64,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
if let Some(m) = &mut model.maven
&& m.sync_error.is_some()
{
let hint = if settings.languages.java.maven.runner_jre.trim().is_empty() {
m.projects.iter().find_map(|p| missing(p.java_version))
} else {
None
};
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
explain(&mut m.sync_error, hint);
}
for g in &mut model.gradle {
@@ -488,32 +492,15 @@ pub async fn maven_exclude_dependency(
.map_err(join_err)?
}
fn cmake_plan(root: &Path, kind: BuildKind, configs: &[ide_run::RunConfig], id: &str) -> IdeResult<compile::BuildPlan> {
let commands = ide_run::cmake_build_configs(configs, id)?;
let title = commands.last().map(|c| c.name.clone()).unwrap_or_default();
let mut steps = Vec::new();
for mut config in commands {
let cwd = ide_core::project_roots::resolve(root, config.cwd.as_deref().unwrap_or(Path::new("")));
ide_run::prepare_cmake(&config, &cwd)?;
if kind == BuildKind::Rebuild && config.args.first().is_some_and(|a| a == "--build") {
config.args.insert(2, "--clean-first".into());
}
steps.push(compile::BuildStep::cmake(&config.command, config.args, cwd, config.env));
}
Ok(compile::BuildPlan { system: compile::BuildSystem::CMake, title, steps })
}
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build)
/// are cleared first; the new messages are published as `build` diagnostics.
#[allow(clippy::too_many_arguments)]
pub async fn build_run(
host: &Host,
window: &str,
kind: BuildKind,
file: Option<std::path::PathBuf>,
cmake: Option<String>,
request: RequestId,
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
@@ -532,10 +519,7 @@ pub async fn build_run(
items: Vec::new(),
});
}
let plan = if let Some(id) = cmake {
let configs = super::run::project_configs(&root, &settings, &config_dir)?;
cmake_plan(&root, kind, &configs, &id)?
} else if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
@@ -632,7 +616,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
&c.window,
c.arg("kind")?,
c.arg("file")?,
c.arg("cmake")?,
c.arg("request")?,
c.arg("clear")?,
c.sender("onEvent")?,
@@ -642,45 +625,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
}
#[cfg(test)]
mod build_tool_settings_tests {
mod gradle_settings_tests {
use super::*;
#[test]
fn cmake_builds_keep_target_configuration_environment_and_directory() {
let root = tempfile::tempdir().unwrap();
let member = root.path().join("nested project");
std::fs::create_dir_all(&member).unwrap();
let config = |id: &str, args: &[&str]| -> ide_run::RunConfig {
serde_json::from_value(serde_json::json!({
"id":id,"name":id,"kind":"cmake","command":"custom-cmake",
"args":args,"cwd":"nested project","env":{"PROFILE":"release"},
"detected":true,"isTest":false,"debugAdapter":null
}))
.unwrap()
};
let configure = config("configure", &["-S", ".", "-B", "release", "-G", "Ninja"]);
let mut build =
config("target", &["--build", "release", "--target", "hello", "--config", "Release", "-j", "8"]);
build.extras.before_launch.push(ide_run::BeforeLaunchTask { kind: "build".into(), target: "configure".into() });
let configs = [configure, build];
for kind in [BuildKind::Build, BuildKind::Rebuild] {
let plan = cmake_plan(root.path(), kind, &configs, "target").unwrap();
assert_eq!(plan.system, compile::BuildSystem::CMake);
assert_eq!(plan.steps.len(), 2);
assert_eq!(plan.steps[0].args, configs[0].args);
let mut args = configs[1].args.clone();
if kind == BuildKind::Rebuild {
args.insert(2, "--clean-first".into());
}
assert_eq!(plan.steps[1].args, args);
for step in plan.steps {
assert_eq!(step.program, "custom-cmake");
assert_eq!(step.cwd, member);
assert_eq!(step.env["PROFILE"], "release");
}
}
}
#[test]
fn builds_apply_the_same_explicit_toolchains_as_runs() {
let temp = tempfile::tempdir().unwrap();
@@ -763,34 +710,6 @@ mod build_tool_settings_tests {
}
}
#[test]
fn model_for_maven_sync_goals_and_compile_uses_runner_jre() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("pom.xml"),
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#
).unwrap();
for name in ["project-jdk", "runner-jdk"] {
let home = root.join(name);
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"21\"\n").unwrap();
}
let mut settings = Settings::default();
settings.languages.java.jdk = "project-jdk".into();
for runner in ["", "runner-jdk"] {
settings.languages.java.maven.runner_jre = runner.into();
let expected = root.join(if runner.is_empty() { "project-jdk" } else { runner });
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert!(!plan.steps.is_empty());
for step in plan.steps {
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
}
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
+5 -11
View File
@@ -103,8 +103,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("db_explain", |c| async move {
let (request, id, statement) =
(c.arg::<RequestId>("request")?, c.arg::<String>("id")?, c.arg::<String>("statement")?);
let schema = c.arg::<Option<String>>("schema")?;
explain(&c.host, &c.window, request, &id, &statement, schema.as_deref()).await
explain(&c.host, &c.window, request, &id, &statement).await
});
d.add("db_history", |c| async move {
let id = c.arg::<String>("id")?;
@@ -131,8 +130,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
c.arg::<u32>("maxRows")?,
);
let schema = c.arg::<Option<String>>("schema")?;
execute(&c.host, &c.window, request, &id, statements, skip, max_rows, schema.as_deref()).await
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
@@ -291,11 +289,10 @@ pub async fn explain(
request: RequestId,
id: &str,
statement: &str,
schema: Option<&str>,
) -> IdeResult<Vec<PlanNode>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let result = db.explain(id, statement, &cancel, schema).await;
let result = db.explain(id, statement, &cancel).await;
host.end_request(request);
result
}
@@ -330,9 +327,7 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
/// Runs `statements` in order on data source `id`, stopping at the first
/// error. Each result reads past its first `skip` rows (a later page) and keeps
/// `max_rows`. `schema` is the console's active context: the connection running
/// each statement is put in it first. Cancel with `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
/// `max_rows`. Cancel with `cancel_request(request)`.
pub async fn execute(
host: &Host,
window: &str,
@@ -341,13 +336,12 @@ pub async fn execute(
statements: Vec<SqlStatement>,
skip: u32,
max_rows: u32,
schema: Option<&str>,
) -> IdeResult<Vec<StatementOutcome>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel, schema).await {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
+1 -3
View File
@@ -28,11 +28,9 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
&mut config.program,
&mut config.args,
&mut config.extra_json,
&settings.languages.go.build_tags,
)?;
+3 -90
View File
@@ -1,11 +1,7 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go};
use std::collections::BTreeMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
struct CratesIo;
impl DependencyVersionSource for CratesIo {
@@ -25,76 +21,6 @@ impl DependencyVersionSource for CratesIo {
}
}
struct GoModules {
root: PathBuf,
env: BTreeMap<String, String>,
}
impl DependencyVersionSource for GoModules {
fn id(&self) -> &'static str {
"go-modules"
}
fn cache_key(&self, package: &str) -> Option<String> {
let mut hash = DefaultHasher::new();
(&self.root, &self.env, package).hash(&mut hash);
Some(format!("{}-{:016x}", self.id(), hash.finish()))
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let search =
self.env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(ide_core::tool_path::search_path);
let go = ide_core::tool_path::find_command("go", &search, &self.root)
.ok_or_else(|| IdeError::unavailable("Go SDK is unavailable"))?;
// A partially edited go.mod is often invalid. Query outside it while
// keeping the SDK and proxy/private module configuration.
let dir = tempfile::tempdir()?;
let mut cmd = Command::new(go);
cmd.current_dir(dir.path())
.env("PATH", search)
.envs(&self.env)
.env("GO111MODULE", "on")
.env("GOFLAGS", "")
.env("GOWORK", "off")
.env("GIT_TERMINAL_PROMPT", "0")
.args(["list", "-m", "-versions", "-f", "{{range .Versions}}{{println .}}{{end}}", "--", package]);
let query = cancel.child_token();
// Fetches run on Host::blocking's Tokio pool, which keeps a runtime current.
let deadline = tokio::spawn({
let query = query.clone();
async move {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
query.cancel();
}
});
let output = ide_tools::run_streamed(cmd, &query, &|_| {});
deadline.abort();
Ok(go::listed_versions(&output?))
}
}
#[cfg(all(test, unix))]
#[path = "dependencies/go_tests.rs"]
mod go_tests;
fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeResult<GoModules> {
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
workspace
.projects
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
});
let settings = match &member {
Some(root) => ide_workspace::SettingsStore::load(config_dir, Some(root)).effective(),
None => project.settings.effective(),
};
let root = member.unwrap_or_else(|| project.root.clone());
let mut env = BTreeMap::new();
ide_sdk::go::configure_environment(&root, &settings, &mut env);
Ok(GoModules { root, env })
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
@@ -105,24 +31,11 @@ pub async fn dependency_versions(
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let config_dir = host.config_dir.clone();
let cancel = host.begin_project_read_request(window, request);
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only {
return Ok(Vec::new());
}
if snap.path.file_name().is_some_and(|name| name == "go.mod") {
let Some(dependency) = offset.and_then(|offset| go::dependency_at(&snap.text, offset)) else {
return Ok(Vec::new());
};
project.require_trust()?;
let source = go_source(&project, &config_dir, snap.path.as_ref())?;
let versions = cache.versions(&source, &dependency.package, &cancel)?;
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
}
if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") {
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
@@ -1,107 +0,0 @@
use super::*;
use ide_workspace::dependencies::VersionCache;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
fn source(dir: &Path, script: &str) -> GoModules {
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let go = bin.join("go");
std::fs::write(&go, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&go, std::fs::Permissions::from_mode(0o755)).unwrap();
GoModules {
root: dir.to_owned(),
env: BTreeMap::from([
("PATH".into(), bin.to_string_lossy().into_owned()),
("PROJECT_ROOT".into(), dir.to_string_lossy().into_owned()),
("GOPROXY".into(), "https://user:[email protected]".into()),
("GOPRIVATE".into(), "private.example/token".into()),
("GOWORK".into(), "broken.work".into()),
("GOFLAGS".into(), "-mod=vendor".into()),
]),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn go_query_is_isolated_and_persists_only_hashed_configuration_and_versions() {
let dir = tempfile::tempdir().unwrap();
let source = source(
dir.path(),
r#"set -eu
[ "$PWD" != "$PROJECT_ROOT" ]
[ "$GOPROXY" = 'https://user:[email protected]' ]
[ "$GOPRIVATE" = 'private.example/token' ]
[ "$GOWORK" = off ]
[ "$GOFLAGS" = '' ]
[ "$GO111MODULE" = on ]
[ "$GIT_TERMINAL_PROMPT" = 0 ]
[ "$1 $2 $3 $4" = 'list -m -versions -f' ]
[ "$5" = '{{range .Versions}}{{println .}}{{end}}' ]
[ "$6 $7" = '-- example.com/Org/Lib/v2' ]
printf 'v2.9.0\nv2.10.0\n'"#,
);
let key = source.cache_key("example.com/Org/Lib/v2").unwrap();
assert_ne!(key, source.cache_key("example.com/org/lib/v2").unwrap());
let mut other = GoModules { root: source.root.join("member"), env: source.env.clone() };
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
other.root = source.root.clone();
other.env.insert("GOPROXY".into(), "https://other.example".into());
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
let cache_dir = dir.path().join("cache");
let disk = cache_dir.clone();
let versions = tokio::task::spawn_blocking(move || {
VersionCache::new(cache_dir).versions(&source, "example.com/Org/Lib/v2", &CancellationToken::new())
})
.await
.unwrap()
.unwrap();
assert_eq!(versions, ["v2.10.0", "v2.9.0"]);
let entries: Vec<_> = std::fs::read_dir(disk).unwrap().map(Result::unwrap).collect();
assert_eq!(entries.len(), 1, "the hashed key passes the cache filename filter");
assert_eq!(entries[0].file_name().to_str().unwrap(), format!("{key}.json"));
let cached = std::fs::read_to_string(entries[0].path()).unwrap();
for sensitive in ["secret", "proxy.example", "private.example", "token", "PROJECT_ROOT"] {
assert!(!cached.contains(sensitive), "configuration must not be persisted: {sensitive}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_go_queries_are_silent_and_not_persisted() {
let dir = tempfile::tempdir().unwrap();
let source = source(dir.path(), "echo 'private module or offline proxy' >&2\nexit 1");
let cache_dir = dir.path().join("cache");
let disk = cache_dir.clone();
let versions = tokio::task::spawn_blocking(move || {
VersionCache::new(cache_dir).versions(&source, "private.example/module", &CancellationToken::new())
})
.await
.unwrap()
.unwrap();
assert!(versions.is_empty());
assert!(!disk.exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stalled_go_queries_obey_the_deadline_and_parent_cancellation() {
for cancel_parent in [false, true] {
let dir = tempfile::tempdir().unwrap();
let source = source(dir.path(), "exec /bin/sleep 60");
let cancel = CancellationToken::new();
let query = cancel.clone();
let started = Instant::now();
let work = tokio::task::spawn_blocking(move || {
VersionCache::new(source.root.join("cache")).versions(&source, "example.com/stalled", &query)
});
if cancel_parent {
tokio::time::sleep(Duration::from_millis(150)).await;
cancel.cancel();
assert!(work.await.unwrap().is_err());
assert!(started.elapsed() < Duration::from_secs(3));
} else {
assert!(work.await.unwrap().unwrap().is_empty());
assert!(started.elapsed() >= Duration::from_secs(10));
assert!(started.elapsed() < Duration::from_secs(15));
assert!(!cancel.is_cancelled(), "a query deadline must not cancel its caller");
}
}
}
+2 -68
View File
@@ -91,10 +91,9 @@ async fn resolve_launch(
}
}
info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
let compile_ms = started.elapsed().as_millis() - lsp_ms;
// The module/project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::module_jdk(&project.root, &module, &settings);
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
// The build model's level includes what the module inherits from its parent POMs.
@@ -154,41 +153,6 @@ fn entry_key(path: &str, windows: bool) -> String {
if windows { key.to_lowercase() } else { key }
}
fn apply_library_classpath(
info: &mut JavaLaunchInfo,
root: &std::path::Path,
libraries: &[ide_core::settings::ProjectLibrary],
test: bool,
) {
use ide_core::settings::DependencyScope;
let entries: Vec<_> = libraries
.iter()
.flat_map(|lib| {
let allowed = match lib.scope {
DependencyScope::Compile | DependencyScope::Runtime => true,
DependencyScope::Test | DependencyScope::Provided => test,
};
lib.classes.iter().filter_map(move |entry| {
ide_sdk::apply::setting_path(root, entry).map(|path| (path.to_string_lossy().into_owned(), allowed))
})
})
.collect();
// JDT may already have returned these archives on either classpath.
for paths in [&mut info.class_paths, &mut info.module_paths] {
paths.retain(|path| {
let matching: Vec<_> = entries.iter().filter(|(entry, _)| same_entry(path, entry, cfg!(windows))).collect();
matching.is_empty() || matching.iter().any(|(_, allowed)| *allowed)
});
}
for (entry, allowed) in entries {
if allowed
&& !info.class_paths.iter().chain(&info.module_paths).any(|path| same_entry(path, &entry, cfg!(windows)))
{
info.class_paths.push(entry);
}
}
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
@@ -256,36 +220,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
use ide_core::settings::{DependencyScope, ProjectLibrary};
let root = tempfile::tempdir().unwrap();
let scopes =
[DependencyScope::Compile, DependencyScope::Runtime, DependencyScope::Test, DependencyScope::Provided];
let libraries: Vec<_> = scopes
.iter()
.enumerate()
.map(|(i, scope)| ProjectLibrary {
classes: vec![format!("lib/{i}.jar")],
scope: *scope,
..Default::default()
})
.collect();
let paths: Vec<_> =
(0..4).map(|i| root.path().join(format!("lib/{i}.jar")).to_string_lossy().into_owned()).collect();
for test in [false, true] {
let mut info =
JavaLaunchInfo { class_paths: paths.clone(), module_paths: paths.clone(), ..Default::default() };
apply_library_classpath(&mut info, root.path(), &libraries, test);
let expected = if test { &paths[..] } else { &paths[..2] };
assert_eq!(info.class_paths, expected);
assert_eq!(info.module_paths, expected);
}
let mut info = JavaLaunchInfo::default();
apply_library_classpath(&mut info, root.path(), &libraries, false);
assert_eq!(info.class_paths, &paths[..2]);
}
/// JDT LS and our build model spell a module output differently on Windows; the
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
#[test]
+50 -659
View File
@@ -7,7 +7,6 @@ use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
use std::path::Path;
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
@@ -86,12 +85,7 @@ const PLUGIN_SERVER: ServerId = ServerId(0);
#[derive(serde::Serialize, serde::Deserialize)]
struct PluginActionRef {
plugin: String,
data: Option<String>,
/// Opened once the action is applied.
open: Option<ide_core::IpcPath>,
/// Listed by the plugin's `file-actions` export, which resolves it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
file: bool,
data: String,
}
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
@@ -396,144 +390,25 @@ pub async fn lsp_references(
libraries: bool,
on_partial: super::Channel<References>,
) -> IdeResult<References> {
let project = host.project(window)?;
let lsp = project.lsp.clone();
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
let cancellation = cancel.clone();
blocking(move || validate_indexed_symbol(&project, &snapshot, Some(&cancellation))).await?;
let _ = on_partial.send(merge_indexed_references(References::default(), symbol));
}
let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel.clone(),
Some(&|refs| {
let refs =
symbol.as_ref().map_or_else(|| refs.clone(), |s| merge_indexed_references(refs.clone(), s));
let _ = on_partial.send(refs);
}),
)
.await;
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
match symbol {
None => result,
Some(symbol) => {
let server = match result {
Ok(server) => server,
Err(e) => References { incomplete: indexed_lsp_error(e)?, ..Default::default() },
};
blocking(move || {
validate_indexed_symbol(&project, &symbol, Some(&cancel))?;
Ok(merge_indexed_references(server, &symbol))
})
.await
}
}
}
.await;
let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel,
Some(&|refs| {
let _ = on_partial.send(refs);
}),
)
.await;
host.end_request(request);
result
}
/// Native servers can decline names owned by indexers. A failed native query
/// keeps the graph usable but must not look like a complete merged result.
fn indexed_lsp_error(error: ide_core::IdeError) -> IdeResult<Option<ReferencesIncomplete>> {
match error.kind {
ErrorKind::NotFound | ErrorKind::Unavailable => Ok(None),
ErrorKind::Protocol => Ok(Some(ReferencesIncomplete::ServerFailed)),
_ => Err(error),
}
}
async fn indexed_symbol(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
) -> IdeResult<Option<crate::plugins::navigation::IndexedSymbol>> {
let Some(plugins) = crate::plugins::editor_plugin_host() else { return Ok(None) };
let project = host.project(window)?;
let client = window.to_owned();
blocking(move || {
let Ok((path, _)) = project.documents.identity(doc) else { return Ok(None) };
let path = ide_plugin::resolve_real(&path);
Ok(plugins.indexed_symbol_at(&project.root, &path, pos, Some(&client)))
})
.await
}
fn validate_indexed_symbol(
project: &crate::Project,
symbol: &crate::plugins::navigation::IndexedSymbol,
cancel: Option<&ide_core::CancellationToken>,
) -> IdeResult<()> {
for file in &symbol.files {
if cancel.is_some_and(|cancel| cancel.is_cancelled()) {
return Err(ide_core::IdeError::cancelled());
}
let stale = || {
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
};
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
return Err(stale());
}
let text = if let Some(doc) = project.documents.by_path(&file.path) {
let snapshot = project.documents.text_snapshot(doc)?;
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
return Err(stale());
}
snapshot.text
} else {
let loaded = project
.documents
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.map_err(|_| stale())?;
if loaded.binary || loaded.large {
return Err(stale());
}
loaded.text
};
if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES
|| crate::plugins::navigation::snapshot_hash(&text) != file.hash
{
return Err(stale());
}
let text = ide_text::Document::new(&text);
for range in &file.ranges {
if [range.start, range.end].into_iter().any(|pos| text.offset_to_pos(text.pos_to_offset(pos)) != pos) {
return Err(ide_core::IdeError::invalid("indexed name is not on a UTF-16 character boundary"));
}
}
}
Ok(())
}
fn merge_indexed_references(mut server: References, symbol: &crate::plugins::navigation::IndexedSymbol) -> References {
let key =
|location: &Location| (dunce::simplified(&location.path).to_path_buf(), location.range, location.uri.clone());
let mut seen: std::collections::HashSet<_> = server.locations.iter().map(key).collect();
for file in &symbol.files {
for range in &file.ranges {
let location = Location::file(file.path.clone(), *range);
if seen.insert(key(&location)) {
server.locations.push(location);
}
}
}
server
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
host.project(window)?.lsp.document_highlights(doc, pos).await
}
@@ -568,7 +443,7 @@ pub async fn lsp_code_actions(
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(plugin, a)| plugin_code_action(&plugin, &project.root, a))
.map(|(plugin, a)| plugin_code_action(&plugin, a))
.filter(|a| {
only.is_empty()
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
@@ -584,26 +459,23 @@ pub async fn lsp_code_actions(
prepare_actions(project, doc, version, actions).await
}
fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> CodeAction {
// With `data` and nothing to apply, the plugin computes the edits when the action is picked.
let lazy = a.edits.is_empty() && a.operations.is_empty() && a.data.is_some();
let (files, open, disabled_reason) = match plugin_file_edits(root, &a) {
Ok((files, open)) => (files, open, None),
Err(reason) => (Vec::new(), None, Some(reason)),
};
let data = (lazy || open.is_some())
.then(|| PluginActionRef {
plugin: plugin.to_owned(),
data: a.data.filter(|_| lazy),
open: open.map(Into::into),
file: a.file_action,
})
.and_then(|r| serde_json::to_value(r).ok());
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
// With `data` and no edits, the plugin computes them when the action is picked.
let lazy = a.edits.is_empty() && a.data.is_some();
let files: Vec<_> = a
.edits
.into_iter()
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
.collect();
let data = a
.data
.filter(|_| lazy)
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
CodeAction {
title: a.title,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason,
disabled_reason: None,
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
command: None,
data,
@@ -611,53 +483,6 @@ fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> C
}
}
/// The action's file operations, then its text edits, as one workspace edit's files, and the
/// file to open. An action with file operations or an `open` target stays inside the project
/// root as a whole: a path outside it disables the action.
fn plugin_file_edits(
root: &Path,
a: &ide_plugin::CodeAction,
) -> Result<(Vec<ide_core::FileEdit>, Option<std::path::PathBuf>), String> {
use ide_core::FileEdit;
use ide_plugin::FileOperation;
let confine = !a.operations.is_empty() || a.open.is_some();
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
let mut files = Vec::new();
let mut created_with_text = Vec::new();
for operation in &a.operations {
match operation {
FileOperation::Create { path, text, overwrite } => {
let path = confined(path)?;
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
if !text.is_empty() {
let start = Range::point(Pos::new(0, 0));
files.push(FileEdit::Edit {
path: path.clone().into(),
edits: vec![TextEdit { range: start, new_text: text.clone() }],
version: None,
});
created_with_text.push(path);
}
}
FileOperation::Rename { from, to } => {
files.push(FileEdit::Rename { from: confined(from)?.into(), to: confined(to)?.into() });
}
FileOperation::Delete { path } => {
files.push(FileEdit::Delete { path: confined(path)?.into(), recursive: false });
}
}
}
for (path, edits) in &a.edits {
let path = if confine { confined(path)? } else { path.clone() };
if created_with_text.contains(&path) {
return Err("put the text of a created file in its `create`, not in an edit".into());
}
files.push(FileEdit::Edit { path: path.into(), edits: edits.clone(), version: None });
}
let open = a.open.as_deref().map(confined).transpose()?;
Ok((files, open))
}
/// A code action resolved by whoever offered it: its language server, or
/// the plugin its `data` names.
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
@@ -674,16 +499,11 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
kind: action.kind.clone(),
preferred: action.is_preferred,
edits: Vec::new(),
operations: Vec::new(),
open: None,
data: r.data,
file_action: r.file,
data: Some(r.data),
};
let plugin = r.plugin.clone();
let invocation = ide_plugin::Invocation { client: Some(window.to_owned()), ..Default::default() };
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending, invocation)).await?;
let root = host.project(window)?.root.clone();
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
@@ -706,7 +526,7 @@ pub async fn lsp_code_action_query(
Ok(result)
}
/// Resolves a code action's edit without applying it.
/// Resolves a code action's edit without applying it (intention preview).
pub async fn lsp_resolve_code_action(
host: &Host,
window: &str,
@@ -734,15 +554,11 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
} else {
action
};
if let Some(reason) = &action.disabled_reason {
return Err(ide_core::IdeError::invalid(reason.clone()));
}
if let Some(edit) = &action.edit {
let documents = project.documents.clone();
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
open_plugin_action_target(&action, window);
if let Some(command) = action.command
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
{
@@ -751,24 +567,6 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
Ok(())
}
/// The plugin and the file its action asks to show once applied.
fn plugin_action_open(action: &CodeAction) -> Option<(String, ide_core::IpcPath)> {
if action.server != PLUGIN_SERVER {
return None;
}
let r = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok())?;
Some((r.plugin, r.open?))
}
/// Opens the file a plugin's action asked to show, now that the action is applied.
fn open_plugin_action_target(action: &CodeAction, window: &str) {
let (Some((plugin, path)), Some(plugins)) = (plugin_action_open(action), crate::plugins::editor_plugin_host())
else {
return;
};
plugins.open_file(&plugin, window, path);
}
/// A command after a code action's edit that no server provides: what other editors'
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
/// is the action; there is nothing to run.
@@ -786,14 +584,6 @@ async fn is_client_follow_up(
}
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? {
let project = host.project(window)?;
return blocking(move || {
validate_indexed_symbol(&project, &symbol, None)?;
Ok(Some(symbol.range))
})
.await;
}
host.project(window)?.lsp.prepare_rename(doc, pos).await
}
@@ -860,133 +650,20 @@ pub async fn lsp_rename(
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
let mut native_owned = false;
let result = async {
if let Some(symbol) = &symbol {
let range = tokio::select! {
_ = cancel.cancelled() => return Err(ide_core::IdeError::cancelled()),
range = project.lsp.prepare_rename(doc, pos) => range?,
};
if range != Some(symbol.range) {
return Ok(Renamed::default());
}
native_owned = true;
}
project.lsp.rename(doc, pos, new_name.clone(), cancel.clone()).await
}
.await;
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let mut renamed = match result {
Ok(renamed) => renamed,
Err(e) if symbol.is_some() && !native_owned => {
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
}
Err(e) => return Err(e),
};
if native_owned
&& !renamed.edit.files.iter().any(|file| match file {
ide_core::FileEdit::Edit { edits, .. } => !edits.is_empty(),
_ => true,
})
{
return Err(ide_core::IdeError::invalid("Native server declined rename"));
}
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ErrorKind::OutOfSync,
"document changed while computing rename; request it again",
));
}
if let Some(symbol) = &symbol {
merge_indexed_rename(&mut renamed, symbol, &new_name, &project.documents);
}
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
if let Some(symbol) = &symbol {
validate_indexed_symbol(&project, symbol, Some(&cancel))?;
}
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
Ok(renamed)
})
.await
}
.await;
let result = project.lsp.rename(doc, pos, new_name, cancel).await;
host.end_request(request);
result
}
fn merge_indexed_rename(
renamed: &mut Renamed,
symbol: &crate::plugins::navigation::IndexedSymbol,
new_name: &str,
documents: &ide_workspace::Documents,
) {
use ide_core::FileEdit;
renamed.preview = true;
let server = std::mem::take(&mut renamed.edit.files);
let mut additions: Vec<Vec<FileEdit>> = vec![vec![]; server.len() + 1];
for file in &symbol.files {
let mut path = dunce::simplified(&file.path).to_path_buf();
let mut at = server.len();
for (index, operation) in server.iter().enumerate() {
match operation {
FileEdit::Edit { path: target, .. } if dunce::simplified(target) == path.as_path() => {
at = index;
break;
}
FileEdit::Rename { from, to } => {
if let Ok(suffix) = path.strip_prefix(dunce::simplified(from)) {
path = dunce::simplified(to).join(suffix);
}
}
_ => {}
}
let mut renamed = result?;
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::OutOfSync,
"document changed while computing rename; request it again",
));
}
let covered: Vec<_> = server[at..]
.iter()
.take_while(|operation| matches!(operation, FileEdit::Edit { .. }))
.filter_map(|operation| match operation {
FileEdit::Edit { path: target, edits, .. } if dunce::simplified(target) == path.as_path() => {
Some(edits)
}
_ => None,
})
.flatten()
.collect();
let edits: Vec<_> = file
.ranges
.iter()
.filter(|range| {
!covered.iter().any(|native| {
native.range == **range || (native.range.start < range.end && range.start < native.range.end)
})
})
.map(|range| TextEdit { range: *range, new_text: new_name.to_owned() })
.collect();
if edits.is_empty() {
continue;
}
additions[at].push(FileEdit::Edit {
path: path.into(),
version: documents
.by_path(&file.path)
.and_then(|id| documents.version(id).ok())
.and_then(|v| i32::try_from(v).ok()),
edits,
});
}
for (index, operation) in server.into_iter().enumerate() {
renamed.edit.files.push(operation);
renamed.edit.files.append(&mut additions[index]);
}
renamed.edit.files.append(additions.last_mut().expect("one trailing batch"));
renamed.edit.deduplicate();
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
Ok(renamed)
})
.await
}
pub async fn lsp_format(
@@ -1000,22 +677,7 @@ pub async fn lsp_format(
}
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
let lsp = host.project(window)?.lsp.clone();
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::InlayHints) else {
return lsp.inlay_hints(doc, range).await;
};
let (result, hints) =
tokio::join!(lsp.inlay_hints(doc, range), plugin_call(move || plugins.provide_inlay_hints(&d, range)));
let hints = hints.unwrap_or_default();
if hints.is_empty() {
return result;
}
let server = match result {
Ok(server) => server,
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Vec::new(),
Err(e) => return Err(e),
};
Ok(crate::plugins::merge_inlay_hints(server, hints, range))
host.project(window)?.lsp.inlay_hints(doc, range).await
}
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
@@ -1169,8 +831,6 @@ pub async fn lsp_execute_code_action(
return Err(ide_core::IdeError::cancelled());
}
let edited = action.edit.is_some();
// A plugin's action may name a file to show once applied; the caller opens it.
let open = plugin_action_open(&action).map(|(_, path)| path);
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
@@ -1186,7 +846,7 @@ pub async fn lsp_execute_code_action(
.execute_action_command(doc, action.server, command, answers, cancel)
.await
.map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()),
None => Ok(open.into_iter().collect()),
None => Ok(Vec::new()),
}
}
.await;
@@ -1343,194 +1003,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn indexed_names_survive_native_refusal_without_hiding_cancellation_or_staleness() {
use ide_core::IdeError;
for kind in [ErrorKind::NotFound, ErrorKind::Unavailable] {
assert_eq!(indexed_lsp_error(IdeError::new(kind, "no native provider")).unwrap(), None);
}
assert_eq!(
indexed_lsp_error(IdeError::new(ErrorKind::Protocol, "not a native identifier")).unwrap(),
Some(ReferencesIncomplete::ServerFailed)
);
for kind in [ErrorKind::Cancelled, ErrorKind::OutOfSync, ErrorKind::InvalidInput] {
assert_eq!(indexed_lsp_error(IdeError::new(kind, "must propagate")).unwrap_err().kind, kind);
}
}
#[test]
fn indexed_results_merge_with_lsp_locations_edits_and_incompleteness() {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let shared = root.join("shared.idx");
let native = root.join("native.txt");
std::fs::write(&shared, "old old").unwrap();
std::fs::write(&native, "old").unwrap();
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
let symbol = IndexedSymbol {
range,
files: vec![IndexedAnchors {
path: shared.clone(),
version: None,
hash: blake3::hash(b"old old"),
ranges: vec![range, other],
}],
};
// Native file URIs have ordinary Windows paths; index snapshots retain
// canonical verbatim paths. Both identify the same file and edit batch.
let server_shared = dunce::simplified(&shared).to_path_buf();
let server_native = dunce::simplified(&native).to_path_buf();
#[cfg(windows)]
assert_ne!(shared, server_shared);
let location = Location::file(server_shared.clone(), range);
let native_location = Location::file(server_native.clone(), range);
let merged = merge_indexed_references(
References {
locations: vec![location.clone(), native_location.clone()],
incomplete: Some(ReferencesIncomplete::Indexing),
libraries_skipped: true,
..Default::default()
},
&symbol,
);
assert_eq!(merged.locations, vec![location, native_location, Location::file(shared.clone(), other)]);
assert_eq!(merged.incomplete, Some(ReferencesIncomplete::Indexing));
assert!(merged.libraries_skipped);
let edit = |path, range| FileEdit::Edit {
path,
version: None,
edits: vec![TextEdit { range, new_text: "new".into() }],
};
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![edit(server_shared.clone().into(), range), edit(server_native.into(), range)],
..Default::default()
},
incomplete: Some(ReferencesIncomplete::FilesSkipped),
..Default::default()
};
let docs = ide_workspace::Documents::new(
std::sync::Arc::new(ide_core::NullSink),
Box::new(|_: &Path, _: Option<&str>| LanguageId::plain_text()),
);
merge_indexed_rename(&mut renamed, &symbol, "new", &docs);
assert!(renamed.preview);
assert_eq!(renamed.incomplete, Some(ReferencesIncomplete::FilesSkipped));
assert_eq!(renamed.edit.files.len(), 2);
docs.prepare_workspace_edit(&mut renamed.edit).unwrap();
docs.apply_workspace_edit(&renamed.edit).unwrap();
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "new new");
assert_eq!(std::fs::read_to_string(&native).unwrap(), "new");
// Native rewrites preserve syntax (for example a shorthand property key).
std::fs::write(&shared, "old old").unwrap();
let mut rewritten = Renamed {
edit: WorkspaceEdit {
files: vec![FileEdit::Edit {
path: shared.clone().into(),
version: None,
edits: vec![TextEdit { range, new_text: "old: new".into() }],
}],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut rewritten, &symbol, "new", &docs);
docs.prepare_workspace_edit(&mut rewritten.edit).unwrap();
docs.apply_workspace_edit(&rewritten.edit).unwrap();
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "old: new new");
std::fs::write(&shared, "new new").unwrap();
// A class rename may move its file before editing the new path. Indexed
// anchors still describe the original file and must join that text batch.
let moved = root.join("moved.idx");
let server_moved = dunce::simplified(&moved).to_path_buf();
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![
FileEdit::Rename { from: server_shared.into(), to: server_moved.clone().into() },
FileEdit::Edit {
path: server_moved.into(),
version: None,
edits: vec![TextEdit { range, new_text: "moved".into() }],
},
],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut renamed, &symbol, "moved", &docs);
docs.prepare_workspace_edit(&mut renamed.edit).unwrap();
docs.apply_workspace_edit(&renamed.edit).unwrap();
assert!(!shared.exists());
assert_eq!(std::fs::read_to_string(&moved).unwrap(), "moved moved");
}
#[test]
fn indexed_edits_yield_to_native_intersections_but_keep_boundary_insertions() {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("shared.idx");
std::fs::write(&path, "old old").unwrap();
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
let symbol = IndexedSymbol {
range,
files: vec![IndexedAnchors {
path: path.clone(),
version: None,
hash: blake3::hash(b"old old"),
ranges: vec![range],
}],
};
let docs = ide_workspace::Documents::new(
std::sync::Arc::new(ide_core::NullSink),
Box::new(|_: &Path, _: Option<&str>| LanguageId::plain_text()),
);
for (start, end, expected) in [
(0, 3, "native old"),
(0, 4, "nativeold"),
(1, 4, "onativeold"),
(1, 2, "onatived old"),
(1, 1, "onativeld old"),
(0, 0, "nativeindexed old"),
(3, 3, "indexednative old"),
(3, 4, "indexednativeold"),
(4, 7, "indexed native"),
] {
let native =
TextEdit { range: Range::new(Pos::new(0, start), Pos::new(0, end)), new_text: "native".into() };
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![FileEdit::Edit {
path: path.clone().into(),
version: None,
edits: vec![native.clone()],
}],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut renamed, &symbol, "indexed", &docs);
let edits: Vec<_> = renamed
.edit
.files
.iter()
.flat_map(|file| match file {
FileEdit::Edit { edits, .. } => edits.clone(),
_ => vec![],
})
.collect();
assert!(edits.contains(&native));
let mut text = ide_text::Document::new("old old");
text.apply_edits(&edits).unwrap();
assert_eq!(text.text(), expected, "native range {start}..{end}");
}
}
#[test]
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
@@ -1538,19 +1010,15 @@ mod tests {
kind: None,
preferred: false,
edits,
operations: Vec::new(),
open: None,
data: data.map(str::to_owned),
file_action: false,
};
let root = Path::new("/p");
let lazy = plugin_code_action("acme.x", root, action(Vec::new(), Some("ref")));
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
assert!(lazy.edit.is_none());
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
assert_eq!((r.plugin.as_str(), r.data.as_deref()), ("acme.x", Some("ref")));
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
// With edits, `data` is the plugin's business: applied as is.
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
let eager = plugin_code_action("acme.x", root, action(vec![("/a".into(), vec![edit])], Some("ref")));
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
assert!(eager.edit.is_some() && eager.data.is_none());
}
@@ -1627,83 +1095,6 @@ mod tests {
assert!(without_generated_helpers(Vec::new()).is_empty());
}
#[test]
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
use ide_core::FileEdit;
use ide_plugin::FileOperation;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
title: "Create view".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: vec![(root.join("a.txt"), vec![TextEdit { range: Range::default(), new_text: "x".into() }])],
operations,
open: Some(root.join("views/home.html")),
data: None,
file_action: true,
};
let created = plugin_code_action(
"acme.x",
&root,
action(vec![
FileOperation::Create { path: "views/home.html".into(), text: "<h1/>".into(), overwrite: false },
FileOperation::Rename { from: root.join("old.txt"), to: root.join("new.txt") },
FileOperation::Delete { path: root.join("gone.txt") },
]),
);
assert_eq!(created.disabled_reason, None);
let files = created.edit.unwrap().files;
let home = root.join("views/home.html");
assert!(matches!(&files[0], FileEdit::Create { path, overwrite: false } if **path == home));
assert!(
matches!(&files[1], FileEdit::Edit { path, edits, .. } if **path == home && edits[0].new_text == "<h1/>")
);
assert!(matches!(&files[2], FileEdit::Rename { to, .. } if **to == root.join("new.txt")));
assert!(matches!(&files[3], FileEdit::Delete { recursive: false, .. }));
assert!(matches!(&files[4], FileEdit::Edit { path, .. } if **path == root.join("a.txt")));
let r: PluginActionRef = serde_json::from_value(created.data.unwrap()).unwrap();
assert_eq!(r.data, None);
assert!(r.file, "resolved by the file-actions export");
let opened = r.open.unwrap().to_path_buf();
assert!(opened.components().eq(dunce::simplified(&home).components()), "{opened:?}");
let outside = tmp.path().join("evil.txt");
let mut open_outside = action(Vec::new());
open_outside.open = Some(outside.clone());
assert!(plugin_code_action("acme.x", &root, open_outside).disabled_reason.is_some());
let mut edit_outside = action(vec![FileOperation::Delete { path: root.join("gone.txt") }]);
edit_outside.edits.push((outside, Vec::new()));
assert!(plugin_code_action("acme.x", &root, edit_outside).disabled_reason.is_some());
let mut edit_created =
action(vec![FileOperation::Create { path: root.join("n.txt"), text: "x".into(), overwrite: false }]);
edit_created.edits = vec![(root.join("n.txt"), Vec::new())];
assert!(plugin_code_action("acme.x", &root, edit_created).disabled_reason.is_some());
for outside in [tmp.path().join("evil.txt"), "../evil.txt".into()] {
for operation in [
FileOperation::Create { path: outside.clone(), text: String::new(), overwrite: false },
FileOperation::Rename { from: root.join("old.txt"), to: outside.clone() },
FileOperation::Delete { path: outside.clone() },
] {
let rejected = plugin_code_action("acme.x", &root, action(vec![operation]));
assert!(rejected.disabled_reason.is_some_and(|r| r.contains("outside the project root")));
assert!(rejected.edit.unwrap().files.is_empty());
}
}
// `data` and nothing to apply: the file-actions export computes it when picked.
let mut lazy = action(Vec::new());
(lazy.edits, lazy.open, lazy.data) = (Vec::new(), None, Some("ref".into()));
let lazy = plugin_code_action("acme.x", &root, lazy);
assert!(lazy.edit.is_none());
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
assert_eq!((r.data.as_deref(), r.file), (Some("ref"), true));
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
+8 -14
View File
@@ -9,20 +9,14 @@ use std::time::Duration;
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
}
pub(super) fn project_configs(
root: &std::path::Path,
settings: &ide_core::settings::Settings,
config_dir: &std::path::Path,
) -> IdeResult<Vec<RunConfig>> {
let user = ide_run::load_user_configs(root)?;
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
let root = project.root.clone();
let user = ide_run::load_user_configs(&root)?;
let root_suppressed = ide_run::load_suppressed_config_ids(&root)?;
let settings = project.settings.effective();
let mut all = detected_and_saved(&root, &settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective();
let saved = ide_run::load_user_configs(&member)?;
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
let configs = detected_and_saved(&member, &settings, &saved, &suppressed);
@@ -30,7 +24,7 @@ pub(super) fn project_configs(
let name = member.file_name().unwrap_or_default().to_string_lossy();
for mut config in configs {
for task in &mut config.extras.before_launch {
if matches!(task.kind.as_str(), "run" | "build") && !task.target.is_empty() {
if task.kind == "run" {
task.target = format!("workspace:{}:{}", key, task.target);
}
}
+2 -13
View File
@@ -181,16 +181,6 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_tomcat_installations(
host: &Host,
window: &str,
) -> IdeResult<Vec<ide_run::tomcat::TomcatInstallation>> {
host.require_trust(window)?;
tokio::task::spawn_blocking(ide_run::tomcat::detect_installations)
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
host.require_trust(window)?;
let project = host.project(window)?;
@@ -222,7 +212,7 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
&project.root,
&model,
id,
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
@@ -288,7 +278,7 @@ pub async fn run_tomcat_sync(
let module = dunce::canonicalize(project.root.join(module))?;
let class_paths: Vec<_> =
class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect();
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id);
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
})
.await
@@ -307,7 +297,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await
});
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
d.add("run_tomcat_installations", |c| async move { run_tomcat_installations(&c.host, &c.window).await });
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
d.add("run_tomcat_sync", |c| async move {
+1 -48
View File
@@ -6,32 +6,19 @@ use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_docker::DockerEndpoint;
use ide_remote::docker::{
ContainerDebug, ContainerRun, DEBUG_BOOTSTRAP, DEBUG_BOOTSTRAP_NAME, compose_container_root, container_config,
container_name, default_container_root,
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Set by the Debug action on a configuration that runs on a container target: the
/// program starts under debugpy and waits for the debugger.
const DEBUG_PARAM: &str = "containerDebug";
/// `config` rewritten to run in a new container of `target`'s image or service.
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
if config.kind == "dotnet" || config.kind == "dotnet-test" {
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let debug = config.extras.params.get(DEBUG_PARAM).is_some_and(|v| v == "true");
if debug && config.debug_adapter.as_deref() != Some("debugpy") {
return Err(IdeError::invalid(format!(
"'{}' can't be debugged in a container: only Python run configurations can",
config.name
)));
}
let endpoint = endpoint(&host.data_dir, &target)?;
let root = root.to_path_buf();
let data_dir = host.data_dir.clone();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
@@ -52,7 +39,6 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
};
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
container_config(
&config,
&ContainerRun {
@@ -64,45 +50,12 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
file_env: &file_env,
name: &name,
user: user(&root, &target),
debug: debugging.as_ref().map(|d| ContainerDebug {
port: d.port,
bootstrap_dir: &d.bootstrap_dir,
debugpy: d.debugpy.as_deref(),
}),
},
)
})
.await
}
/// What a debug run mounts and publishes.
struct Debugging {
port: u16,
bootstrap_dir: PathBuf,
debugpy: Option<PathBuf>,
}
impl Debugging {
fn prepare(data_dir: &Path) -> IdeResult<Self> {
// The bootstrap is (re)written on each run, so it matches this version of the IDE.
let bootstrap_dir = data_dir.join("container-debug");
std::fs::create_dir_all(&bootstrap_dir)?;
std::fs::write(bootstrap_dir.join(DEBUG_BOOTSTRAP_NAME), DEBUG_BOOTSTRAP)?;
// The managed install the local debugger falls back to (`ide_dap::adapter::prepare_debugpy`).
let tools = ide_dap::adapter::tools_root().unwrap_or_else(|| ide_tools::tools_dir(data_dir));
let lib = ide_tools::python_lib_dir(&tools, ide_dap::adapter::DEBUGPY_TOOL);
let debugpy = lib.join("debugpy").join("__init__.py").is_file().then_some(lib);
Ok(Self { port: free_port()?, bootstrap_dir, debugpy })
}
}
/// A port nobody listens on here: debugpy listens on the same port in the container,
/// published on the loopback interface.
fn free_port() -> IdeResult<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
+16 -128
View File
@@ -45,15 +45,6 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
.map_err(join_err)
}
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
pub async fn sdk_python_interpreter(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<Sdk>> {
host.require_trust(window)?;
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || Ok(ide_sdk::python_interpreter_sdk(&path, root.as_deref())))
.await
.map_err(join_err)?
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
@@ -158,56 +149,25 @@ pub async fn sdk_php_composer_install(
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(
host: &Host,
window: &str,
request: RequestId,
on_groups: Channel<Vec<ide_sdk::libraries::LibraryGroup>>,
) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let cancel = host.begin_project_request(window, request);
let libraries = {
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
tokio::task::spawn_blocking(move || {
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
let _ = on_groups.send(groups);
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
})
})
};
let dependencies = {
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
if cancel.is_cancelled() {
return Vec::new();
}
let model = crate::ops::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
})
.collect();
if cancel.is_cancelled() {
return Vec::new();
}
let groups = ide_sdk::libraries::dependency_groups(&root, &settings, &deps);
if !cancel.is_cancelled() {
let _ = on_groups.send(groups.clone());
}
groups
})
};
let (libraries, dependencies) = tokio::join!(libraries, dependencies);
host.end_request(request);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat()))
.collect();
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
})
.await
.map_err(join_err)
}
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
@@ -297,10 +257,6 @@ pub async fn sdk_python_pip(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add(
"sdk_python_interpreter",
|c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
@@ -312,9 +268,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
"sdk_php_composer_install",
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move {
sdk_project_libraries(&c.host, &c.window, c.arg("request")?, c.sender("onGroups")?).await
});
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
});
@@ -347,69 +301,3 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::HostConfig;
use std::path::Path;
use std::sync::Arc;
fn fake_venv(root: &Path, name: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let venv = root.join(name);
std::fs::create_dir_all(venv.join("bin")).unwrap();
std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.12.1\n").unwrap();
let python = venv.join("bin/python");
std::fs::write(&python, "#!/bin/sh\necho 3.12.1\n").unwrap();
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
python
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_hand_picked_venv_is_resolved_like_a_detected_interpreter() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let sdk = sdk_python_interpreter(&host, "main", fake_venv(&root, ".venv")).await.unwrap().unwrap();
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (project)", "venv"));
let python = fake_venv(&base, "erp19py3.12");
let sdk = sdk_python_interpreter(&host, "main", python.parent().unwrap().parent().unwrap().into())
.await
.unwrap()
.unwrap();
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (erp19py3.12)", "venv"));
assert_eq!(&*sdk.path, python.as_path());
host.project("main")
.unwrap()
.settings
.set(
ide_workspace::SettingsScope::Project,
"languages.python.interpreter",
serde_json::to_value(&sdk.path).unwrap(),
)
.unwrap();
host.close_project("main");
host.open_project("main", &root).unwrap();
let detected = sdk_detect(&host, "main", SdkKind::Python).await.unwrap();
assert!(detected.iter().any(|s| s.path == sdk.path && s.name == sdk.name));
assert_eq!(sdk_python_effective(&host, "main", None).unwrap(), Some(sdk.path));
assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none());
host.close_project("main");
}
}
File diff suppressed because it is too large. Load diff
-245
View File
@@ -1,245 +0,0 @@
//! Versioned, project-scoped diagnostics for unsaved editor buffers.
use super::{PluginHost, ProviderKind, editor_plugin_host};
use ide_core::{CancellationToken, Diagnostic, DocId, IdeEvent, SharedSink};
use ide_plugin::ProviderDocument;
use ide_text::AppliedChange;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents};
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Weak};
use std::time::Duration;
const DEBOUNCE: Duration = Duration::from_millis(300);
struct Document {
snapshot: ProviderDocument,
cancel: CancellationToken,
sources: HashSet<String>,
}
#[derive(Default)]
pub(super) struct State {
closed: bool,
documents: HashMap<DocId, Document>,
generations: HashMap<String, u64>,
}
/// One incarnation of one project's documents, shared by all its editors.
pub(crate) struct Diagnostics {
state: Arc<Mutex<State>>,
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: Weak<Documents>,
root: std::path::PathBuf,
}
impl Diagnostics {
pub fn new(
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: &Arc<Documents>,
root: &Path,
) -> Arc<Self> {
Arc::new(Self {
state: Arc::default(),
sink,
runtime,
documents: Arc::downgrade(documents),
root: root.to_path_buf(),
})
}
fn emit(&self, path: &Path, plugin: &str, items: Vec<Diagnostic>) {
self.sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin-provider:{plugin}"),
items,
});
}
fn clear_document(&self, doc: &Document) {
doc.cancel.cancel();
for plugin in &doc.sources {
self.emit(&doc.snapshot.path, plugin, Vec::new());
}
}
pub fn close(&self) {
let mut state = self.state.lock();
state.closed = true;
for (_, doc) in state.documents.drain() {
self.clear_document(&doc);
}
}
fn schedule(&self, id: DocId, state: &mut State) {
let Some(doc) = state.documents.get_mut(&id) else { return };
doc.cancel.cancel();
doc.cancel = CancellationToken::new();
let Some(host) = editor_plugin_host() else { return };
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
return;
}
// Register only once per project incarnation; no project is retained by the host.
let weak = Arc::downgrade(&self.state);
{
let mut projects = host.diagnostic_projects.lock();
projects.retain(|(state, _)| state.strong_count() > 0);
if !projects.iter().any(|(state, _)| state.ptr_eq(&weak)) {
projects.push((weak.clone(), self.sink.clone()));
}
}
let root = self.root.clone();
let (mut snapshot, cancel, sink, documents) =
(doc.snapshot.clone(), doc.cancel.clone(), self.sink.clone(), self.documents.clone());
let lifecycle_generations = state.generations.clone();
self.runtime.spawn(async move {
tokio::time::sleep(DEBOUNCE).await;
if cancel.is_cancelled() || weak.strong_count() == 0 {
return;
}
let _ = tokio::task::spawn_blocking(move || {
let Some(documents) = documents.upgrade() else { return };
let Ok(current) = documents.snapshot(id) else { return };
if u64::from(current.version) != snapshot.version
|| current.path.0 != snapshot.path
|| current.language.as_str() != snapshot.language
|| cancel.is_cancelled()
{
return;
}
snapshot.text = current.text;
drop(documents);
let generations = host.providers.diagnostic_generations(&snapshot.language);
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
} else {
Vec::new()
};
let Some(state) = weak.upgrade() else { return };
let mut state = state.lock();
if state.closed || cancel.is_cancelled() {
return;
}
let matching: HashSet<_> = host
.diagnostic_plugins(Some(&root), &snapshot.language)
.into_iter()
.map(|p| p.id().to_owned())
.filter(|p| lifecycle_generations.get(p) == state.generations.get(p))
.collect();
let latest = host.providers.diagnostic_generations(&snapshot.language);
let Some(doc) = state.documents.get_mut(&id) else { return };
if doc.snapshot.version != snapshot.version || doc.snapshot.path != snapshot.path {
return;
}
let mut current = HashSet::new();
for (plugin, items) in results {
if !matching.contains(&plugin)
|| !generations.get(&plugin).is_some_and(|seq| latest.get(&plugin) == Some(seq))
{
continue;
}
current.insert(plugin.clone());
sink.emit(IdeEvent::Diagnostics {
path: snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items,
});
}
for plugin in doc.sources.difference(&current) {
sink.emit(IdeEvent::Diagnostics {
path: snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items: Vec::new(),
});
}
doc.sources = current;
})
.await;
});
}
}
impl DocumentObserver for Diagnostics {
fn opened(&self, info: &DocumentInfo) {
let mut state = self.state.lock();
if state.closed {
return;
}
if let Some(old) = state.documents.remove(&info.id) {
self.clear_document(&old);
}
if info.large || info.uri.is_some() {
return;
}
state.documents.insert(
info.id,
Document {
snapshot: ProviderDocument {
path: info.path.0.clone(),
language: info.language.to_string(),
version: u64::from(info.version),
text: String::new(),
},
cancel: CancellationToken::new(),
sources: HashSet::new(),
},
);
self.schedule(info.id, &mut state);
}
fn changed(&self, id: DocId, path: &Path, version: u32, _: &[AppliedChange], _text: &dyn Fn() -> String) {
let mut state = self.state.lock();
if state.closed {
return;
}
let Some(doc) = state.documents.get_mut(&id) else { return };
if doc.snapshot.path != path {
self.clear_document(doc);
doc.sources.clear();
}
doc.snapshot.path = path.to_path_buf();
doc.snapshot.version = u64::from(version);
self.schedule(id, &mut state);
}
fn closed(&self, id: DocId, _: &Path) {
if let Some(doc) = self.state.lock().documents.remove(&id) {
self.clear_document(&doc);
}
}
}
impl Drop for Diagnostics {
fn drop(&mut self) {
self.close();
}
}
pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
impl PluginHost {
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
// Clone before taking a project's state lock (edits register in the other order).
let projects = self.diagnostic_projects.lock().clone();
for (state, sink) in projects {
if let Some(state) = state.upgrade() {
let mut state = state.lock();
// A completed answer can still be waiting for another provider.
*state.generations.entry(plugin.to_owned()).or_default() += 1;
for doc in state.documents.values_mut() {
if doc.sources.remove(plugin) {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items: Vec::new(),
});
}
}
}
}
}
}
-318
View File
@@ -1,318 +0,0 @@
//! `[[contributes.endpoints]]`: HTTP routes a plugin finds in a project. The UI
//! asks for them (`plugin_endpoints`); the host caches each plugin's list per
//! project until a file matching its globs changes, then emits `EndpointsChanged`.
use super::{PluginEvent, PluginHost};
use ide_core::{IdeResult, ipc_path::to_ipc};
use ide_extension::{Capability, Package, UiDecl, decode};
use ide_plugin::{Endpoint, HostError, Invocation};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
pub(super) const POINT: &str = "endpoints";
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Decl {
files: Vec<String>,
}
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
let d: Decl = decode(POINT, value)?;
pkg.manifest.needs_api("contributes.endpoints", 3)?;
if pkg.manifest.wasm.is_none() || d.files.is_empty() || d.files.len() > 64 {
return Err("contributes.endpoints needs code and 1–64 file globs".into());
}
for file in &d.files {
if file.is_empty()
|| file.len() > 1024
|| file.starts_with('/')
|| file.contains('\\')
|| file.contains(':')
|| file.split('/').any(|p| p == "..")
{
return Err("endpoint file globs must be project-relative".into());
}
matcher(file).map_err(|e| e.to_string())?;
}
let key = serde_json::to_string(&d.files).map_err(|e| e.to_string())?;
Ok(UiDecl::new(key, "HTTP endpoints", serde_json::to_value(d).map_err(|e| e.to_string())?))
}
fn matcher(glob: &str) -> Result<globset::GlobMatcher, globset::Error> {
Ok(globset::GlobBuilder::new(glob).literal_separator(true).build()?.compile_matcher())
}
/// A route as the UI gets it.
#[derive(Clone, Debug, PartialEq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginEndpoint {
/// `None` accepts any method.
pub method: Option<String>,
/// Path parameters in braces: `/photos/{id}`.
pub path: String,
pub framework: String,
/// Display name, e.g. `PhotosController#show`.
pub handler: Option<String>,
/// Where the route is declared.
pub location: ide_core::Location,
}
#[derive(Default)]
pub(super) struct Endpoints {
/// Bumped by every invalidation: a list computed across one is kept but not trusted.
epoch: u64,
cache: BTreeMap<(String, PathBuf), Cached>,
}
struct Cached {
list: Arc<Vec<PluginEndpoint>>,
fresh: bool,
}
impl PluginHost {
/// `plugin`'s routes in the project `root`: the cached list while no file
/// matching its globs changed, else computed again. Runs plugin code, so
/// call it on a blocking thread. A failed or invalid run keeps the last list.
pub fn endpoints(&self, plugin: &str, root: &Path) -> IdeResult<Arc<Vec<PluginEndpoint>>> {
let Some(workspace) = self.shared.workspace() else { return Ok(Arc::default()) };
let open = |root: &Path| workspace.projects().iter().any(|r| r == root) && workspace.trusted(root);
let loaded = self.get(plugin)?;
let key = (plugin.to_owned(), root.to_path_buf());
let epoch = {
let mut state = self.endpoints.lock();
state.cache.retain(|(_, r), _| open(r));
if !open(root) || !self.endpoint_eligible(&loaded, root) {
return Ok(Arc::default());
}
if let Some(cached) = state.cache.get(&key).filter(|c| c.fresh) {
return Ok(cached.list.clone());
}
state.epoch
};
let invocation = Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() };
let result = self
.code_call(plugin, invocation, "endpoints", |rt, ctx| rt.endpoints(ctx, Duration::from_secs(2)))
.and_then(|items| {
validate(root, items, &*workspace).map_err(|e| ide_core::IdeError::invalid(e.to_string()))
});
let mut state = self.endpoints.lock();
// Disabled, replaced or closed meanwhile: its routes are gone.
let current = self.plugins.read().get(plugin).is_some_and(|p| Arc::ptr_eq(p, &loaded));
if !current || !open(root) || !self.endpoint_eligible(&loaded, root) {
state.cache.remove(&key);
return Ok(Arc::default());
}
let fresh = state.epoch == epoch;
let list = match result {
Ok(list) => Arc::new(list),
Err(e) => {
tracing::warn!(plugin, "endpoints failed: {e}");
state.cache.get(&key).map(|c| c.list.clone()).unwrap_or_default()
}
};
state.cache.insert(key, Cached { list: list.clone(), fresh });
Ok(list)
}
pub(super) fn clear_plugin_endpoints(&self, plugin: &str) {
let mut state = self.endpoints.lock();
state.epoch += 1;
let roots: Vec<PathBuf> = state.cache.keys().filter(|(id, _)| id == plugin).map(|(_, r)| r.clone()).collect();
state.cache.retain(|(id, _), _| id != plugin);
drop(state);
for root in roots {
self.emit(None, PluginEvent::EndpointsChanged { plugin_id: plugin.to_owned(), root: to_ipc(&root).into() });
}
}
/// Called by the project's file watcher; doesn't run plugin code.
pub fn invalidate_endpoints(&self, root: &Path, paths: &[PathBuf]) {
let rel: Vec<&Path> = paths.iter().filter_map(|p| p.strip_prefix(root).ok()).collect();
if rel.is_empty() {
return;
}
let declarations = self.ui.items(POINT, None);
let mut state = self.endpoints.lock();
state.epoch += 1;
let mut changed = Vec::new();
for ((plugin, project), cached) in &mut state.cache {
let matches = project == root
&& declarations.iter().filter(|d| &d.plugin_id == plugin).any(|d| {
d.decl["files"].as_array().is_some_and(|globs| {
globs
.iter()
.filter_map(|g| matcher(g.as_str()?).ok())
.any(|m| rel.iter().any(|p| m.is_match(p)))
})
});
if matches && cached.fresh {
cached.fresh = false;
changed.push(plugin.clone());
}
}
drop(state);
for plugin_id in changed {
self.emit(None, PluginEvent::EndpointsChanged { plugin_id, root: to_ipc(root).into() });
}
}
fn endpoint_eligible(&self, plugin: &super::LoadedPlugin, root: &Path) -> bool {
plugin.status().is_live()
&& plugin.dev_root.as_deref().is_none_or(|r| r == root)
&& !self.ui.items(POINT, Some(plugin.id())).is_empty()
&& {
let meta = plugin.meta.lock();
meta.pending.is_empty() && meta.granted.contains(&Capability::ProjectRead)
}
}
}
fn validate(
root: &Path,
items: Vec<Endpoint>,
workspace: &dyn super::PluginWorkspace,
) -> Result<Vec<PluginEndpoint>, HostError> {
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10,000 endpoints".into()));
}
let mut bytes = 0usize;
let mut document_bytes = 0usize;
let mut texts: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
let mut validated = Vec::with_capacity(items.len());
for e in items {
bytes += e.path.len()
+ e.method.as_ref().map_or(0, String::len)
+ e.framework.len()
+ e.handler.as_ref().map_or(0, String::len)
+ e.location.path.as_os_str().len();
if bytes > 4 << 20
|| e.path.len() > 8192
|| !e.path.starts_with('/')
|| e.path.chars().any(char::is_whitespace)
|| e.path.chars().any(char::is_control)
|| e.method.as_ref().is_some_and(|m| {
m.is_empty() || m.len() > 32 || !m.bytes().all(|b| b.is_ascii_uppercase() || b == b'-')
})
|| e.framework.is_empty()
|| e.framework.len() > 256
|| e.framework.chars().any(char::is_control)
|| e.handler.as_ref().is_some_and(|h| h.len() > 1024 || h.chars().any(char::is_control))
{
return Err(HostError::Invalid("invalid endpoint or batch larger than 4 MiB".into()));
}
let path = ide_plugin::resolve_in_root(root, &e.location.path.to_string_lossy()).map_err(HostError::Invalid)?;
if !texts.contains_key(&path) {
let text = workspace
.buffer_text(&path)
.map(Ok)
.unwrap_or_else(|| ide_plugin::read_text(&path).map_err(HostError::Invalid));
let text = text?;
document_bytes += text.len();
if document_bytes > 16 << 20 {
return Err(HostError::Invalid("endpoint declaration documents exceed 16 MiB".into()));
}
texts.insert(
path.clone(),
text.split('\n').map(|line| line.trim_end_matches('\r').encode_utf16().count()).collect(),
);
}
let lines = &texts[&path];
let range = e.location.range;
if range.start > range.end
|| [range.start, range.end]
.iter()
.any(|p| lines.get(p.line as usize).is_none_or(|len| p.col as usize > *len))
{
return Err(HostError::Invalid("endpoint location is outside its UTF-16 document range".into()));
}
validated.push(PluginEndpoint {
method: e.method,
path: e.path,
framework: e.framework,
handler: e.handler,
location: ide_core::Location { path: to_ipc(&path).into(), range, uri: None },
});
}
Ok(validated)
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
struct Workspace;
impl super::super::PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
unreachable!()
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
unreachable!()
}
}
fn endpoint(path: PathBuf) -> Endpoint {
Endpoint {
method: Some("GET".into()),
path: "/users/{id}".into(),
framework: "Rails".into(),
handler: None,
location: ide_plugin::Location { path, range: Range::new(Pos::new(0, 2), Pos::new(0, 3)) },
}
}
#[test]
fn validates_entire_rooted_batch_with_utf16_positions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let file = root.join("routes.rb");
std::fs::write(&file, "😀x\n").unwrap();
let good = endpoint(file.clone());
let items = validate(&root, vec![good.clone()], &Workspace).unwrap();
assert_eq!(items[0].location.range.end.col, 3);
assert!(items[0].handler.is_none());
for bad in [
Endpoint { method: Some("GET\nPOST".into()), ..good.clone() },
Endpoint { path: "https://example.com".into(), ..good.clone() },
Endpoint { path: "/bad\r\nPOST /".into(), ..good.clone() },
Endpoint { handler: Some("bad\nhandler".into()), ..good.clone() },
Endpoint {
location: ide_plugin::Location {
path: file.clone(),
range: Range::new(Pos::new(0, 0), Pos::new(0, 4)),
},
..good.clone()
},
Endpoint {
location: ide_plugin::Location { path: root.join("../outside"), range: Range::default() },
..good.clone()
},
] {
assert!(validate(&root, vec![good.clone(), bad], &Workspace).is_err());
}
assert!(validate(&root, vec![good.clone(); 10_001], &Workspace).is_err());
#[cfg(unix)]
{
let outside = tempfile::NamedTempFile::new().unwrap();
std::os::unix::fs::symlink(outside.path(), root.join("escape")).unwrap();
assert!(validate(&root, vec![endpoint(root.join("escape"))], &Workspace).is_err());
}
}
}
-201
View File
@@ -1,201 +0,0 @@
//! Project-relative event filters, kept with runtime subscriptions across idle reclaim.
use super::{LoadedPlugin, PluginHost};
use globset::{GlobBuilder, GlobMatcher};
use ide_plugin::{EventKind, HostError, WorkbenchEvent};
use std::{
collections::{BTreeSet, HashMap},
path::Path,
};
#[derive(Clone, Default)]
pub(super) struct Subscriptions {
pub kinds: HashMap<String, BTreeSet<EventKind>>,
pub paths: HashMap<String, HashMap<String, PathFilter>>,
}
#[derive(Clone)]
pub(super) struct PathFilter {
pub globs: Vec<String>,
matchers: Vec<GlobMatcher>,
}
impl PathFilter {
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
if !matches!(kind, "file-opened" | "file-saved" | "files-changed") {
return Err(HostError::Invalid(
"subscribe-paths supports file-opened, file-saved and files-changed".into(),
));
}
if globs.len() > 64 {
return Err(HostError::Invalid("at most 64 event path globs".into()));
}
let mut matchers = Vec::new();
for input in &globs {
let glob = input.trim_start_matches("./");
if glob.is_empty()
|| input.len() > 1024
|| glob.starts_with('/')
|| glob.contains('\\')
|| glob.contains(':')
|| glob.chars().any(char::is_control)
|| glob.split('/').any(|c| c == "..")
{
return Err(HostError::Invalid(
"event globs must be project-relative paths, at most 1024 bytes each".into(),
));
}
matchers.push(
GlobBuilder::new(glob)
.literal_separator(true)
.build()
.map_err(|e| HostError::Invalid(format!("invalid event glob: {e}")))?
.compile_matcher(),
);
}
Ok(Self { globs, matchers })
}
pub fn matches(&self, root: &Path, path: &Path) -> bool {
let path = ide_plugin::resolve_real(path);
let root = ide_plugin::resolve_real(root);
path.strip_prefix(&root).is_ok_and(|relative| self.matchers.iter().any(|m| m.is_match(relative)))
}
}
impl Subscriptions {
pub fn remove(&mut self, plugin: &str) -> bool {
self.kinds.remove(plugin).is_some() | self.paths.remove(plugin).is_some()
}
/// Subscribes to every event of `kind`, dropping its path filter.
pub fn set(&mut self, plugin: &str, kind: EventKind) -> bool {
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind.name()).is_some());
self.kinds.entry(plugin.to_owned()).or_default().insert(kind) | removed
}
pub fn set_paths(&mut self, plugin: &str, kind: &str, filter: PathFilter) -> bool {
let mut changed = false;
if let Some(kind) = EventKind::parse(kind) {
changed = self.kinds.entry(plugin.to_owned()).or_default().insert(kind);
}
let previous = self.paths.entry(plugin.to_owned()).or_default().insert(kind.to_owned(), filter.clone());
previous.is_none_or(|old| old.globs != filter.globs) | changed
}
pub fn unsubscribe(&mut self, plugin: &str, kind: &str) -> bool {
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind).is_some());
let off = EventKind::parse(kind)
.is_some_and(|kind| self.kinds.get_mut(plugin).is_some_and(|kinds| kinds.remove(&kind)));
removed | off
}
}
impl PluginHost {
pub(super) fn filter_path_events(
shared: &super::api::Shared,
plugin: &LoadedPlugin,
client: Option<&str>,
events: &[WorkbenchEvent],
subs: &Subscriptions,
) -> Vec<WorkbenchEvent> {
let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() };
let workspace = shared.workspace();
events
.iter()
.filter_map(|event| {
if let WorkbenchEvent::FilesChanged(batch) = event {
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
let root = workspace.as_ref()?.project_of(client?)?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
}
let paths = |paths: &[String]| {
paths
.iter()
.filter(|path| {
let path = Path::new(path);
path.is_absolute() && filter.matches(&root, path)
})
.cloned()
.collect::<Vec<_>>()
};
let batch = ide_plugin::FilesChanged {
created: paths(&batch.created),
modified: paths(&batch.modified),
removed: paths(&batch.removed),
};
return (!(batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty()))
.then_some(WorkbenchEvent::FilesChanged(batch));
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
_ => return Some(event.clone()),
};
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
if !path.is_absolute() {
return None;
}
let workspace = workspace.as_ref()?;
let root = match client {
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;
}
filter.matches(&root, path).then(|| event.clone())
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_validation_is_bounded_and_project_relative() {
for invalid in ["", "/etc/*", "../*", "a/../b", "C:/a", "a\\b", "a\nb", "["] {
assert!(PathFilter::new("file-opened", vec![invalid.into()]).is_err(), "{invalid:?}");
}
assert!(PathFilter::new("editor-focused", vec!["*".into()]).is_err());
assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err());
assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err());
assert!(PathFilter::new("file-saved", vec![]).is_ok());
assert!(PathFilter::new("files-changed", vec![]).is_ok());
}
#[test]
fn matchers_use_or_semantics_and_component_boundaries() {
let root = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(root.path()).unwrap();
let filter = PathFilter::new("file-opened", vec!["./config/*.yaml".into(), "src/**/*.rs".into()]).unwrap();
for path in ["config/a.yaml", "src/main.rs", "src/深い/a.rs"] {
assert!(filter.matches(&root, &root.join(path)), "{path}");
}
for path in ["config/nested/a.yaml", "other/a.yaml", "src/a.ts"] {
assert!(!filter.matches(&root, &root.join(path)), "{path}");
}
assert!(!filter.matches(&root, &root.with_extension("other").join("config/a.yaml")));
assert!(!PathFilter::new("file-opened", vec![]).unwrap().matches(&root, &root.join("a")));
}
#[test]
fn subscriptions_replace_atomically_and_legacy_subscribe_clears_filters() {
let mut subs = Subscriptions::default();
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec!["*.rs".into()]).unwrap());
assert!(subs.kinds["plugin"].contains(&EventKind::FileOpened));
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec![]).unwrap());
assert!(subs.paths["plugin"]["file-opened"].globs.is_empty());
subs.set("plugin", EventKind::FileOpened);
assert!(subs.paths["plugin"].is_empty());
subs.set_paths("plugin", "file-saved", PathFilter::new("file-saved", vec!["*".into()]).unwrap());
assert!(subs.unsubscribe("plugin", "file-saved"));
assert!(subs.remove("plugin"));
assert!(subs.paths.is_empty() && subs.kinds.is_empty());
}
}
@@ -1,663 +0,0 @@
//! Filesystem batches retain their project incarnation until delivery.
use super::{PluginHost, WorkbenchEvent};
use ide_core::ipc_path::to_ipc;
use ide_plugin::FilesChanged;
use std::path::{Path, PathBuf};
use std::sync::{
Arc, Weak,
atomic::{AtomicBool, Ordering},
};
#[derive(Clone)]
pub(super) struct Source {
active: Weak<AtomicBool>,
}
impl Source {
pub fn same(&self, other: &Self) -> bool {
self.active.ptr_eq(&other.active)
}
fn live(&self) -> bool {
self.active.upgrade().is_some_and(|active| active.load(Ordering::Acquire))
}
}
pub(super) fn retain_live(events: &mut Vec<WorkbenchEvent>, source: Option<&Source>) {
if source.is_some_and(|source| !source.live()) {
events.retain(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)));
}
}
pub(super) fn append(events: &mut Vec<WorkbenchEvent>, incoming: Vec<WorkbenchEvent>) {
for event in incoming {
if let WorkbenchEvent::FilesChanged(batch) = &event
&& let Some(WorkbenchEvent::FilesChanged(previous)) =
events.iter_mut().find(|e| matches!(e, WorkbenchEvent::FilesChanged(_)))
{
use std::collections::BTreeMap;
#[derive(Clone, Copy)]
enum Change {
Created,
Modified,
Removed,
}
use Change::*;
let mut paths = BTreeMap::new();
for (kind, entries) in
[(Created, &previous.created), (Modified, &previous.modified), (Removed, &previous.removed)]
{
for path in entries {
paths.insert(path.clone(), kind);
}
}
for (next, entries) in [(Created, &batch.created), (Modified, &batch.modified), (Removed, &batch.removed)] {
for path in entries {
let kind = match (paths.get(path), next) {
(Some(Created), Created | Modified) => Created,
(Some(Removed), Created) | (Some(Modified), Created | Modified) => Modified,
(Some(Removed), Modified) => Removed,
(_, next) => next,
};
paths.insert(path.clone(), kind);
}
}
*previous = FilesChanged::default();
for (path, kind) in paths {
match kind {
Created => previous.created.push(path),
Modified => previous.modified.push(path),
Removed => previous.removed.push(path),
}
}
} else {
events.push(event);
}
}
}
impl PluginHost {
pub(crate) fn post_files_changed(
&self,
client: &str,
root: &Path,
active: &Arc<AtomicBool>,
batch: &ide_vfs::FsBatch,
) {
if !active.load(Ordering::Acquire) || self.shared.project_of(client).as_deref() != Some(root) {
return;
}
let paths =
|paths: &[PathBuf]| paths.iter().filter(|p| p.starts_with(root)).map(|p| to_ipc(p)).collect::<Vec<_>>();
let batch = FilesChanged {
created: paths(&batch.created),
modified: paths(&batch.modified),
removed: paths(&batch.removed),
};
if batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty() {
return;
}
self.post_events_from(
Some(client),
vec![WorkbenchEvent::FilesChanged(batch)],
Some(Source { active: Arc::downgrade(active) }),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn files_changed_host(root: &Path) -> PluginHost {
use super::super::Consent;
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.to_path_buf()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
host
}
fn delta(kind: usize, path: &str) -> WorkbenchEvent {
let mut batch = FilesChanged::default();
match kind {
0 => batch.created.push(path.into()),
1 => batch.modified.push(path.into()),
_ => batch.removed.push(path.into()),
}
WorkbenchEvent::FilesChanged(batch)
}
#[test]
fn files_changed_preserves_ordered_path_transitions() {
for (first, next, expected) in
[(0, 0, 0), (0, 1, 0), (0, 2, 2), (1, 0, 1), (1, 1, 1), (1, 2, 2), (2, 0, 1), (2, 1, 2), (2, 2, 2)]
{
let mut events = vec![delta(first, "/project/path")];
append(&mut events, vec![delta(next, "/project/path")]);
assert_eq!(events, [delta(expected, "/project/path")], "transition {first} -> {next}");
}
let mut events = vec![delta(0, "/project/transient"), delta(2, "/project/old-name")];
append(&mut events, vec![delta(2, "/project/transient"), delta(0, "/project/new-name")]);
append(&mut events, vec![delta(1, "/project/transient")]);
// Seed through append too: one coalesced batch keeps both sides of a rename.
let mut merged = vec![];
append(&mut merged, events);
assert_eq!(
merged,
[WorkbenchEvent::FilesChanged(FilesChanged {
created: vec!["/project/new-name".into()],
removed: vec!["/project/old-name".into(), "/project/transient".into()],
..Default::default()
})]
);
}
#[test]
fn files_changed_stale_admission_cannot_replace_reopened_queue() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let old = Arc::new(AtomicBool::new(true));
let new = Arc::new(AtomicBool::new(true));
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
std::thread::scope(|scope| {
let host = &host;
let old = &old;
let root = &root;
scope.spawn(move || {
assert!(old.load(Ordering::Acquire));
entered.send(()).unwrap();
released.recv().unwrap();
host.post_events_from(
Some("main"),
vec![delta(0, &to_ipc(&root.join("stale.rs")))],
Some(Source { active: Arc::downgrade(old) }),
);
});
waiting.recv().unwrap();
old.store(false, Ordering::Release);
host.post_files_changed(
"main",
root,
&new,
&ide_vfs::FsBatch { created: vec![root.join("new.rs")], ..Default::default() },
);
release.send(()).unwrap();
});
let queues = host.queues.lock();
let queue = queues.values().next().expect("new session queue");
assert!(queue.files_source.as_ref().unwrap().same(&Source { active: Arc::downgrade(&new) }));
assert_eq!(queue.events, [delta(0, &to_ipc(&root.join("new.rs")))]);
}
#[test]
fn files_changed_restarts_after_an_actor_drops_queued_work() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
let crash = actor
.submit(None, move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Err::<(), _>(ide_plugin::CallError::Trap(ide_plugin::CANCELLED.into()))
})
.unwrap();
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("discarded.rs")))]);
host.flush_events();
release.send(()).unwrap();
assert!(crash.wait().is_err());
host.flush_events();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("after-restart.rs")))]);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(1)), "dropped actor work must not keep its mailbox alive");
let data = host.run_command("files-events", "events", None, Default::default()).unwrap().unwrap();
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(
batches,
[FilesChanged { created: vec![to_ipc(&root.join("after-restart.rs"))], ..Default::default() }]
);
}
#[test]
fn files_changed_actor_mailbox_is_bounded_and_yields_to_commands() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
for i in 0..600 {
host.post_events(Some("main"), vec![delta(i % 3, &to_ipc(&root.join(format!("path-{i}"))))]);
host.flush_events();
}
let pending = plugin.batches.lock().pending;
// A command gets a scheduling turn after one coalesced delivery.
let command = actor.submit(None, move |rt| rt.run_command("events", None, Default::default())).unwrap();
release.send(()).unwrap();
let data = command.wait().unwrap().unwrap();
assert_eq!(pending, 1, "flushes must not grow the actor job queue");
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].created.len(), 200);
assert_eq!(batches[0].modified.len(), 200);
assert_eq!(batches[0].removed.len(), 200);
assert!(host.wait_events(Duration::from_secs(10)));
}
#[test]
fn files_changed_rechecks_filters_after_a_busy_actor_command() {
use std::time::Duration;
for globs in ["", "other/**", "config/*.yaml"] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
let filter =
actor.submit(None, move |rt| rt.run_command("filter", Some(globs), Default::default())).unwrap();
host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(FilesChanged {
created: vec![to_ipc(&root.join("config/a.yaml")), to_ipc(&root.join("src/a.rs"))],
modified: vec![to_ipc(&root.join("config/b.yaml"))],
removed: vec![to_ipc(&root.join("config/c.yaml"))],
})],
);
host.flush_events();
release.send(()).unwrap();
filter.wait().unwrap();
assert!(host.wait_events(Duration::from_secs(10)));
let data = host.run_command("files-events", "events", None, Default::default()).unwrap();
if globs == "config/*.yaml" {
let batches: Vec<FilesChanged> = serde_json::from_str(&data.unwrap()).unwrap();
assert_eq!(
batches,
[FilesChanged {
created: vec![to_ipc(&root.join("config/a.yaml"))],
modified: vec![to_ipc(&root.join("config/b.yaml"))],
removed: vec![to_ipc(&root.join("config/c.yaml"))],
}]
);
} else {
assert_eq!(data, None, "excluded batches must not invoke the guest handler");
}
}
}
#[test]
fn files_changed_delivery_honors_filters_registered_during_activation() {
use super::super::Consent;
use std::time::{Duration, Instant};
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/path-events");
let copy = root.join("activation-fixture");
std::fs::create_dir_all(copy.join("dist")).unwrap();
std::fs::write(
copy.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml"))
.unwrap()
.replace("[\"on-command:*\"]", "[\"on-command:*\", \"on-event:file-opened\"]"),
)
.unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), copy.join("dist/plugin.wasm")).unwrap();
host.install_from_dir(&copy, &Consent::all()).unwrap();
host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&root.join("src/excluded.rs")))]);
host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(host.wait_events(Duration::from_secs(10)));
assert_eq!(host.run_command("test.path-events", "count", None, Default::default()).unwrap(), None);
}
#[test]
fn files_changed_rechecks_runtime_unsubscribe_on_busy_actor() {
use super::super::Consent;
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
let copy = root.join("runtime-fixture");
std::fs::create_dir_all(copy.join("dist")).unwrap();
std::fs::write(
copy.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml")).unwrap().replace(", \"on-event:files-changed\"", ""),
)
.unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), copy.join("dist/plugin.wasm")).unwrap();
host.install_from_dir(&copy, &Consent::all()).unwrap();
host.run_command("files-events", "filter", Some("**"), Default::default()).unwrap();
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
let off = actor.submit(None, |rt| rt.run_command("unsubscribe", None, Default::default())).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("excluded.rs")))]);
host.flush_events();
release.send(()).unwrap();
off.wait().unwrap();
assert!(host.wait_events(Duration::from_secs(10)));
assert_eq!(host.run_command("files-events", "events", None, Default::default()).unwrap(), None);
}
#[test]
fn files_changed_dropped_actor_job_releases_mailbox_before_restart() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Err::<(), _>(ide_plugin::CallError::Trap("test actor exit".into()))
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("dropped.rs")))]);
host.flush_events();
assert_eq!(plugin.batches.lock().pending, 1);
release.send(()).unwrap();
actor.stop(true);
assert_eq!(plugin.batches.lock().pending, 0, "dropped closures must release their batch ticket");
assert!(plugin.batches.lock().files_pending.is_empty());
host.set_enabled("files-events", false).unwrap();
host.set_enabled("files-events", true).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("fresh.rs")))]);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(10)));
let data = host.run_command("files-events", "events", None, Default::default()).unwrap().unwrap();
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(batches, [FilesChanged { created: vec![to_ipc(&root.join("fresh.rs"))], ..Default::default() }]);
}
#[test]
fn files_changed_old_ticket_cannot_clear_successor_mailbox_or_running_state() {
use super::super::{BatchTicket, Batches, PendingFiles};
let batches = Arc::new(parking_lot::Mutex::new(Batches::default()));
let key = (Some("main".into()), Some(PathBuf::from("/project")));
let old = Arc::new(());
let new = Arc::new(());
let ticket = BatchTicket { batches: batches.clone(), files_key: Some(key.clone()), token: old };
{
let mut state = batches.lock();
state.pending = 2;
state.running = Some([ide_plugin::EventKind::FilesChanged].into());
state.running_token = Some(new.clone());
state.files_pending.insert(
key.clone(),
PendingFiles {
queue: super::super::EventQueue {
since: std::time::Instant::now(),
events: vec![delta(0, "/project/new")],
files_source: None,
},
actor: std::sync::Weak::new(),
token: new.clone(),
},
);
}
drop(ticket);
let state = batches.lock();
assert_eq!(state.pending, 1);
assert!(Arc::ptr_eq(&state.files_pending[&key].token, &new));
assert!(Arc::ptr_eq(state.running_token.as_ref().unwrap(), &new));
assert!(state.running.is_some());
}
#[test]
fn every_runtime_event_has_a_matching_manifest_activation() {
for kind in ide_plugin::EventKind::ALL {
if kind != ide_plugin::EventKind::Scheduled {
assert!(ide_extension::EVENT_KINDS.contains(&kind.name()));
}
}
for kind in ide_extension::EVENT_KINDS {
assert!(ide_plugin::EventKind::parse(kind).is_some());
}
}
#[test]
fn batches_coalesce_without_losing_categories_or_paths() {
let mut events = vec![];
for i in 0..600 {
append(
&mut events,
vec![WorkbenchEvent::FilesChanged(FilesChanged {
created: vec![format!("/project/{i}")],
modified: vec!["/project/shared".into()],
removed: vec!["/project/gone".into()],
})],
);
}
let [WorkbenchEvent::FilesChanged(batch)] = events.as_slice() else { panic!("one coalesced batch") };
assert_eq!(batch.created.len(), 600);
assert_eq!(batch.modified, ["/project/shared"]);
assert_eq!(batch.removed, ["/project/gone"]);
}
#[test]
fn closed_incarnations_drop_only_filesystem_events() {
let active = Arc::new(AtomicBool::new(true));
let source = Source { active: Arc::downgrade(&active) };
let mut events = vec![WorkbenchEvent::FilesChanged(FilesChanged::default()), WorkbenchEvent::Scheduled];
retain_live(&mut events, Some(&source));
assert_eq!(events.len(), 2);
active.store(false, Ordering::Release);
retain_live(&mut events, Some(&source));
assert_eq!(events, [WorkbenchEvent::Scheduled]);
assert!(!source.same(&Source { active: Arc::downgrade(&Arc::new(AtomicBool::new(true))) }));
}
struct Workspace(PathBuf);
impl super::super::PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![self.0.clone(), self.0.join("other")]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, ide_plugin::HostError> {
unreachable!()
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, window: &str) -> Option<PathBuf> {
match window {
"main" => Some(self.0.clone()),
"other" => Some(self.0.join("other")),
_ => None,
}
}
fn publish_diagnostics(
&self,
_: &str,
_: &Path,
_: Vec<ide_core::Diagnostic>,
) -> Result<(), ide_plugin::HostError> {
Ok(())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_shutdown_discards_queued_filesystem_batches() {
use super::super::Consent;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir(&root).unwrap();
let backend = crate::Host::new(crate::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: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
backend.open_project("main", &root).unwrap();
let active = backend.project("main").unwrap().files_events_active.clone();
let plugins =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
plugins.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
let batch = ide_vfs::FsBatch { created: vec![root.join("new.rs")], ..Default::default() };
plugins.post_files_changed("main", &root, &active, &batch);
assert!(!plugins.queues.lock().is_empty());
backend.shutdown().await;
plugins.flush_events();
assert_eq!(plugins.instances(), 0, "host shutdown must discard queued filesystem batches");
plugins.post_files_changed("main", &root, &active, &batch);
assert!(plugins.queues.lock().is_empty(), "a retained watcher cannot post after host shutdown");
}
#[test]
fn producer_respects_project_incarnation_window_and_dev_root() {
use super::super::{Consent, PluginSource};
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir(root.join("other")).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
host.set_project_policy(Arc::new(|_| true));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.load_dir(&fixture, PluginSource::Dev, Some(root.clone()));
host.grant("files-events", &Consent::all()).unwrap();
let active = Arc::new(AtomicBool::new(true));
let batch = ide_vfs::FsBatch {
created: vec![root.join("new.rs"), root.parent().unwrap().join("outside.rs")],
..Default::default()
};
host.post_files_changed("closed", &root, &active, &batch);
host.post_files_changed("main", &root.join("wrong"), &active, &batch);
host.post_files_changed(
"other",
&root.join("other"),
&active,
&ide_vfs::FsBatch { modified: vec![root.join("other/a.rs")], ..Default::default() },
);
host.post_files_changed(
"main",
&root,
&active,
&ide_vfs::FsBatch {
vcs: vec![root.join(".git/HEAD")],
ignored: vec![root.join(".env")],
..Default::default()
},
);
host.flush_events();
assert_eq!(host.instances(), 0, "unrelated windows, roots, and metadata do not wake a dev plugin");
host.post_files_changed("main", &root, &active, &batch);
active.store(false, Ordering::Release);
host.flush_events();
assert_eq!(host.instances(), 0, "closing before delivery discards the batch");
host.post_files_changed("main", &root, &active, &batch);
assert!(host.queues.lock().is_empty(), "a closing watcher cannot post more events");
let reopened = Arc::new(AtomicBool::new(true));
host.post_files_changed("main", &root, &reopened, &batch);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(10)));
let inv = ide_plugin::Invocation {
client: Some("main".into()),
workspace_root: Some(root.clone()),
..Default::default()
};
let data = host.run_command("files-events", "events", None, inv).unwrap().unwrap();
let received: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(received, [FilesChanged { created: vec![to_ipc(&root.join("new.rs"))], ..Default::default() }]);
}
#[test]
fn queued_filter_replacement_and_pause_do_not_activate() {
use super::super::{Consent, event_paths::PathFilter};
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
let active = Arc::new(AtomicBool::new(true));
let filter = |globs: Vec<String>| {
host.shared.subscriptions.lock().set_paths(
"files-events",
"files-changed",
PathFilter::new("files-changed", globs).unwrap(),
);
};
let batch = ide_vfs::FsBatch {
created: vec![root.join("config/a.yaml"), root.join("src/unrelated.rs")],
modified: vec![root.join("config/b.yaml")],
removed: vec![root.join("config/c.yaml")],
..Default::default()
};
filter(vec!["config/*.yaml".into()]);
host.post_files_changed("main", &root, &active, &batch);
assert!(!host.queues.lock().is_empty());
filter(vec!["src/**".into()]);
host.flush_events();
assert_eq!(host.instances(), 0, "replacement excludes paths already queued");
filter(vec!["config/*.yaml".into()]);
host.post_files_changed("main", &root, &active, &batch);
filter(vec![]);
host.flush_events();
assert_eq!(host.instances(), 0, "pausing suppresses a queued batch before activation");
host.post_files_changed("main", &root, &active, &batch);
assert!(host.queues.lock().is_empty(), "paused paths never queue work");
}
}
-6
View File
@@ -510,8 +510,6 @@ fn check_value(key: &str, value: &str) -> Result<(), String> {
if lower.contains("javascript:")
|| lower.contains("expression(")
|| lower.contains("@import")
// These CSS image/URL functions accept bare strings, bypassing url().
|| ["image(", "image-set(", "src("].iter().any(|function| lower.contains(function))
|| value.contains('\\')
{
return Err("unsafe value".into());
@@ -609,10 +607,6 @@ mod tests {
r##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
r##"<svg><rect style="background-image:image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:-webkit-image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:image('https://evil/x.png')"/></svg>"##,
r##"<svg><rect style="fill:src('https://evil/x.svg')"/></svg>"##,
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
r##"<svg><![CDATA[x]]></svg>"##,
r##"<svg><a href="#x"/></svg>"##,
+13 -16
View File
@@ -83,14 +83,23 @@ pub async fn plugin_navigation_index(
}
(snapshot.text, Some(snapshot.version), snapshot.language)
} else {
let Ok(loaded) = project.documents.load_file_bounded(&path, super::navigation::MAX_DOCUMENT_BYTES)
else {
use std::io::Read;
if !std::fs::metadata(&path)
.is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64)
{
continue;
}
let Ok(file) = std::fs::File::open(&path) else {
continue;
};
if loaded.binary || loaded.large {
let mut text = String::new();
if file
.take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64)
.read_to_string(&mut text)
.is_err()
{
continue;
}
let text = loaded.text;
let language = project.languages.detect(&path, text.lines().next());
(text, None, language)
};
@@ -117,7 +126,6 @@ pub async fn plugin_navigation_index(
workspace_root: Some(project.root.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
},
)?;
// A document edit while WASM was executing must not publish stale ranges.
@@ -132,17 +140,6 @@ pub async fn plugin_navigation_index(
result
}
/// The HTTP routes `plugin_id` reports for this window's project.
pub async fn plugin_endpoints(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
) -> IdeResult<Vec<crate::plugins::endpoints::PluginEndpoint>> {
let project = host.project(window)?;
blocking(move || Ok(plugins.endpoints(&plugin_id, &project.root)?.as_ref().clone())).await
}
pub async fn plugin_navigation_goto(
host: &crate::Host,
plugins: Arc<PluginHost>,
@@ -1,94 +0,0 @@
use ide_core::Range;
use ide_lsp::types::{InlayHint, InlayKind};
use ide_plugin::PluginInlayHint;
pub(crate) fn merge(server: Vec<InlayHint>, plugin: Vec<PluginInlayHint>, range: Range) -> Vec<InlayHint> {
let inside = |h: &PluginInlayHint| {
let at = (h.position.line, h.position.col);
(range.start.line, range.start.col) <= at && at <= (range.end.line, range.end.col)
};
let mut out = server;
out.extend(plugin.into_iter().filter(inside).map(|h| InlayHint {
position: h.position,
label: h.label,
kind: match h.kind.as_str() {
"type" => InlayKind::Type,
"parameter" => InlayKind::Parameter,
_ => InlayKind::Other,
},
padding_left: h.padding_left,
padding_right: h.padding_right,
tooltip: h.tooltip,
parts: Vec::new(),
}));
out.sort_by_key(|h| (h.position.line, h.position.col));
out
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::Pos;
fn plugin(line: u32, col: u32, label: &str, kind: &str) -> PluginInlayHint {
PluginInlayHint {
position: Pos::new(line, col),
label: label.into(),
kind: kind.into(),
tooltip: Some("from the plugin".into()),
padding_left: true,
padding_right: false,
}
}
fn server(line: u32, col: u32, label: &str) -> InlayHint {
InlayHint {
position: Pos::new(line, col),
label: label.into(),
kind: InlayKind::Parameter,
padding_left: false,
padding_right: true,
tooltip: None,
parts: Vec::new(),
}
}
fn summary(hints: &[InlayHint]) -> Vec<(u32, u32, String)> {
hints.iter().map(|h| (h.position.line, h.position.col, h.label.clone())).collect()
}
#[test]
fn plugin_hints_join_the_servers_in_position_order() {
let range = Range::new(Pos::new(0, 0), Pos::new(10, 0));
let merged = merge(
vec![server(2, 4, "name:"), server(5, 1, "id:")],
vec![plugin(3, 9, ": string", "type"), plugin(2, 1, "GET /orders", "other")],
range,
);
assert_eq!(
summary(&merged),
[
(2, 1, "GET /orders".to_owned()),
(2, 4, "name:".to_owned()),
(3, 9, ": string".to_owned()),
(5, 1, "id:".to_owned())
]
);
let typed = merged.iter().find(|h| h.label == ": string").unwrap();
assert!(matches!(typed.kind, InlayKind::Type));
assert!(typed.padding_left && !typed.padding_right);
assert_eq!(typed.tooltip.as_deref(), Some("from the plugin"));
}
#[test]
fn unknown_kinds_are_other_and_hints_outside_the_range_are_dropped() {
let range = Range::new(Pos::new(2, 5), Pos::new(4, 3));
let merged = merge(
Vec::new(),
vec![plugin(2, 4, "before", "type"), plugin(3, 0, "in", "route"), plugin(4, 4, "after", "type")],
range,
);
assert_eq!(summary(&merged), [(3, 0, "in".to_owned())]);
assert!(matches!(merged[0].kind, InlayKind::Other));
}
}
-221
View File
@@ -24,134 +24,6 @@ pub const ACTIVE_FILE: &str = "active";
/// `<root>/previous`: the version an update replaced (for rollback).
pub const PREVIOUS_FILE: &str = "previous";
#[derive(serde::Serialize, serde::Deserialize)]
pub(super) struct PendingUninstall {
pub id: String,
pub name: String,
pub version: String,
pub flat: bool,
pub secrets: Vec<String>,
#[serde(default)]
pub error: Option<String>,
}
impl PendingUninstall {
fn validate(&self) -> io::Result<()> {
if self.id.is_empty()
|| self.id.starts_with('.')
|| Path::new(&self.id).components().count() != 1
|| !matches!(Path::new(&self.id).components().next(), Some(std::path::Component::Normal(_)))
{
return Err(io::ErrorKind::InvalidData.into());
}
version_dir(&self.version)?;
Ok(())
}
pub(super) fn package_dir(&self, root: &Path) -> PathBuf {
let dir = root.join(&self.id);
if self.flat { dir } else { dir.join(&self.version) }
}
}
pub(super) fn check_not_symlink(path: &Path) -> io::Result<()> {
match std::fs::symlink_metadata(path) {
Ok(meta) => {
#[cfg(windows)]
let reparse = {
use std::os::windows::fs::MetadataExt;
meta.file_attributes() & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT != 0
};
#[cfg(not(windows))]
let reparse = false;
if meta.file_type().is_symlink() || reparse {
Err(io::Error::other("refusing symlink in plugin cleanup path"))
} else {
Ok(())
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub(super) fn check_cleanup_parent(root: &Path, path: &Path) -> io::Result<()> {
let relative = path.strip_prefix(root).map_err(io::Error::other)?;
let mut parent = root.to_path_buf();
for part in relative.parent().unwrap_or(Path::new("")).components() {
if !matches!(part, std::path::Component::Normal(_)) {
return Err(io::Error::other("invalid plugin cleanup path"));
}
parent.push(part);
check_not_symlink(&parent)?;
}
Ok(())
}
fn uninstall_file(root: &Path, id: &str) -> PathBuf {
root.join(".uninstall").join(format!("{id}.json"))
}
pub(super) fn remember_uninstall(root: &Path, pending: &PendingUninstall, dir: &Path) -> io::Result<()> {
use std::io::Write;
pending.validate()?;
if pending.package_dir(root) != dir {
return Err(io::ErrorKind::InvalidData.into());
}
let file = uninstall_file(root, &pending.id);
check_not_symlink(file.parent().unwrap())?;
std::fs::create_dir_all(file.parent().unwrap())?;
check_not_symlink(&file)?;
let mut out = tempfile::NamedTempFile::new_in(file.parent().unwrap())?;
out.write_all(&serde_json::to_vec(pending)?)?;
out.as_file().sync_all()?;
out.persist(file).map(|_| ()).map_err(|e| e.error)
}
pub(super) fn pending_uninstalls(root: &Path) -> Vec<PendingUninstall> {
if check_not_symlink(&root.join(".uninstall")).is_err() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(root.join(".uninstall")) else { return Vec::new() };
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
return None;
}
let read = || -> io::Result<PendingUninstall> {
check_not_symlink(&path)?;
let pending: PendingUninstall = serde_json::from_slice(&std::fs::read(&path)?)?;
pending.validate()?;
if path != uninstall_file(root, &pending.id) {
return Err(io::Error::from(io::ErrorKind::InvalidData));
}
Ok(pending)
};
match read() {
Ok(pending) => Some(pending),
Err(e) => {
tracing::warn!(path = %path.display(), "cannot recover pending plugin uninstall: {e}");
None
}
}
})
.collect()
}
pub(super) fn uninstall_pending(root: &Path, id: &str) -> io::Result<bool> {
uninstall_file(root, id).try_exists()
}
pub(super) fn forget_uninstall(root: &Path, id: &str) -> io::Result<()> {
check_not_symlink(&root.join(".uninstall"))?;
match std::fs::remove_file(uninstall_file(root, id)) {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
fn nonce() -> String {
let n = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
format!("{n:x}-{}", std::process::id())
@@ -347,99 +219,6 @@ pub fn recover(dir: &Path) {
mod tests {
use super::*;
#[test]
fn pending_uninstall_records_reject_paths_outside_the_package() {
let tmp = tempfile::tempdir().unwrap();
let mut pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: vec!["key".into()],
error: None,
};
remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).unwrap();
assert_eq!(pending_uninstalls(tmp.path())[0].secrets, ["key"]);
for (id, version) in [
("../outside", "1"),
("/outside", "1"),
(".data", "p"),
("other", "1"),
("p", "../outside"),
("p", "1/nested"),
] {
pending.id = id.into();
pending.version = version.into();
std::fs::write(uninstall_file(tmp.path(), "p"), serde_json::to_vec(&pending).unwrap()).unwrap();
assert!(pending_uninstalls(tmp.path()).is_empty(), "{id}/{version}");
}
pending.id = "invalid-1-0-0".into();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("other/1.0.0")).is_err());
forget_uninstall(tmp.path(), "p").unwrap();
assert!(pending_uninstalls(tmp.path()).is_empty());
}
#[cfg(unix)]
#[test]
fn pending_uninstall_records_do_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: Vec::new(),
error: None,
};
let record = outside.path().join("p.json");
std::fs::write(&record, "untouched").unwrap();
let parent = tmp.path().join(".uninstall");
symlink(outside.path(), &parent).unwrap();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(forget_uninstall(tmp.path(), "p").is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
std::fs::remove_file(&parent).unwrap();
std::fs::create_dir(&parent).unwrap();
symlink(&record, uninstall_file(tmp.path(), "p")).unwrap();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
forget_uninstall(tmp.path(), "p").unwrap();
symlink(&record, parent.join("p.tmp")).unwrap();
remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).unwrap();
assert_eq!(pending_uninstalls(tmp.path()).len(), 1);
assert_eq!(std::fs::read_to_string(&record).unwrap(), "untouched");
}
#[cfg(windows)]
#[test]
fn pending_uninstall_records_do_not_follow_junctions() {
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let record = outside.path().join("p.json");
std::fs::write(&record, "untouched").unwrap();
let status = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(tmp.path().join(".uninstall"))
.arg(outside.path())
.status()
.unwrap();
assert!(status.success());
let pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: Vec::new(),
error: None,
};
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(forget_uninstall(tmp.path(), "p").is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
assert_eq!(std::fs::read_to_string(record).unwrap(), "untouched");
}
#[test]
fn failed_swap_keeps_the_old_version() {
let tmp = tempfile::tempdir().unwrap();
-1
View File
@@ -429,7 +429,6 @@ impl PluginHost {
level: ide_plugin::NotificationLevel::Warning,
title: format!("{} was disabled", plugin.manifest().name),
body: format!("The marketplace revoked this plugin: {reason}"),
actions: Vec::new(),
},
);
}
+47 -479
View File
@@ -17,17 +17,12 @@
mod api;
pub mod commit;
pub(crate) mod diagnostics;
pub mod endpoints;
mod event_paths;
mod files_changed;
mod hover;
mod icon_extensions;
mod icon_patterns;
mod icon_settings;
mod icons;
pub mod index_ops;
mod inlay_hints;
mod install;
pub mod intellij;
mod market;
@@ -49,7 +44,6 @@ pub(crate) use hover::merge as merge_hover;
pub use icons::{
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
};
pub(crate) use inlay_hints::merge as merge_inlay_hints;
pub use market::{
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
read_url as read_market_url, resolve as resolve_market_href,
@@ -115,7 +109,6 @@ struct Links {
}
struct Meta {
uninstalling: bool,
status: PluginStatus,
error: Option<String>,
warnings: Vec<String>,
@@ -143,7 +136,6 @@ struct Meta {
impl Meta {
fn new() -> Self {
Self {
uninstalling: false,
status: PluginStatus::Disabled,
error: None,
warnings: Vec::new(),
@@ -190,7 +182,6 @@ const LATENCY_WINDOW: usize = 128;
struct EventQueue {
since: Instant,
events: Vec<WorkbenchEvent>,
files_source: Option<files_changed::Source>,
}
/// Most events queued per plugin and window; older ones are dropped.
@@ -221,39 +212,6 @@ struct Batches {
pending: u32,
/// Kinds of the batch being handled right now.
running: Option<BTreeSet<EventKind>>,
files_pending: HashMap<FilesQueueKey, PendingFiles>,
running_token: Option<Arc<()>>,
}
type FilesQueueKey = (Option<String>, Option<PathBuf>);
struct PendingFiles {
queue: EventQueue,
actor: std::sync::Weak<Actor>,
token: Arc<()>,
}
/// Dropping a posted job must release its mailbox even when the actor never runs it.
struct BatchTicket {
batches: Arc<Mutex<Batches>>,
files_key: Option<FilesQueueKey>,
token: Arc<()>,
}
impl Drop for BatchTicket {
fn drop(&mut self) {
let mut batches = self.batches.lock();
if let Some(key) = &self.files_key
&& batches.files_pending.get(key).is_some_and(|p| Arc::ptr_eq(&p.token, &self.token))
{
batches.files_pending.remove(key);
}
batches.pending = batches.pending.saturating_sub(1);
if batches.running_token.as_ref().is_some_and(|token| Arc::ptr_eq(token, &self.token)) {
batches.running = None;
batches.running_token = None;
}
}
}
impl LoadedPlugin {
@@ -363,9 +321,6 @@ pub struct PluginHost {
/// `[[contributes.providers]]` of enabled plugins.
providers: Arc<providers::Providers>,
navigation: Mutex<navigation::NavigationIndex>,
endpoints: Mutex<endpoints::Endpoints>,
web_ptys: Mutex<BTreeMap<String, Vec<web::WebPtySession>>>,
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -422,8 +377,7 @@ impl PluginHost {
let ui = UiStore::new();
let index = Arc::new(ContributionIndex::new(
plugins_dir.join(".cache").join("contributions"),
// Revalidate SVGs accepted before URL-valued CSS functions were rejected.
concat!(env!("CARGO_PKG_VERSION"), ":svg-2"),
env!("CARGO_PKG_VERSION"),
));
ui.set_index(Some(index.clone()));
let languages = PluginLanguages::new();
@@ -456,9 +410,6 @@ impl PluginHost {
run_detectors,
providers,
navigation: Mutex::default(),
endpoints: Mutex::default(),
web_ptys: Mutex::default(),
diagnostic_projects: Mutex::default(),
index,
plugins: RwLock::default(),
state: Mutex::new(state),
@@ -494,47 +445,6 @@ impl PluginHost {
/// Instantiates no Wasm: code runs on its activation events.
pub fn load_all(&self) {
let _guard = self.lifecycle.lock();
for pending in install::pending_uninstalls(&self.plugins_dir) {
if self.plugins.read().contains_key(&pending.id) {
continue;
}
let dir = pending.package_dir(&self.plugins_dir);
let mut manifest = placeholder_manifest(&dir);
manifest.id = pending.id.clone();
manifest.name = pending.name;
manifest.version = pending.version;
manifest.contributes.insert(
providers::SECRETS_POINT.into(),
toml::Value::Array(
pending
.secrets
.into_iter()
.map(|name| {
toml::Value::Table(toml::Table::from_iter([("name".into(), toml::Value::String(name))]))
})
.collect(),
),
);
let mut meta = Meta::new();
meta.status = PluginStatus::Failed;
meta.uninstalling = true;
meta.error = Some(
pending.error.unwrap_or_else(|| "Uninstall incomplete; retry Uninstall to finish cleanup.".into()),
);
self.plugins.write().insert(
pending.id,
Arc::new(LoadedPlugin {
pkg: Package::new(manifest, dir),
icon: None,
invalid: false,
source: PluginSource::Installed,
dev_root: None,
meta: Mutex::new(meta),
batches: Arc::default(),
runtime: Mutex::new(None),
}),
);
}
let dirs = package_dirs(&self.plugins_dir);
self.migrate_grants(&dirs);
for dir in dirs {
@@ -788,19 +698,9 @@ impl PluginHost {
Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect())
}
/// The UI language windows run in, as `locale()` reports it to plugin code.
pub fn set_ui_locale(&self, tag: &str) {
*self.shared.locale.write() = tag.to_owned();
}
pub fn ui_locale(&self) -> String {
self.shared.locale.read().clone()
}
pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
{
let mut state = self.state.lock();
if enabled {
@@ -824,7 +724,6 @@ impl PluginHost {
pub fn reload(&self, id: &str) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone());
self.unload(id);
let new_id = self.load_dir(&dir, source, root);
@@ -886,11 +785,6 @@ impl PluginHost {
let _guard = self.lifecycle.lock();
let (pkg, set) = self.validate_package(src)?;
let id = pkg.id().to_owned();
if let Some(plugin) = self.plugins.read().get(&id) {
self.check_not_uninstalling(plugin)?;
} else {
self.check_uninstall_pending(&id)?;
}
if let Some(existing) = self.plugins.read().get(&id)
&& existing.source != PluginSource::Installed
{
@@ -965,13 +859,6 @@ impl PluginHost {
/// Makes `previous` the active version of `id` again and drops `failed`
/// (its directory, its consent). Called with the plugin unloaded or not.
fn roll_back(&self, id: &str, previous: &str, failed: &str, grant: Option<ledger::Grant>) {
let ready = match self.get(id) {
Ok(plugin) => self.check_not_uninstalling(&plugin),
Err(_) => self.check_uninstall_pending(id),
};
if ready.is_err() {
return;
}
let root = self.plugins_dir.join(id);
self.unload(id);
if let Err(e) = install::activate_version(&root, previous) {
@@ -1015,7 +902,6 @@ impl PluginHost {
level: ide_plugin::NotificationLevel::Warning,
title: format!("{name} update rolled back"),
body: message.clone(),
actions: Vec::new(),
},
);
self.changed();
@@ -1026,7 +912,6 @@ impl PluginHost {
pub fn roll_back_update(&self, id: &str) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
if plugin.source != PluginSource::Installed {
return Err(IdeError::invalid("only installed plugins have versions to roll back to"));
}
@@ -1045,7 +930,6 @@ impl PluginHost {
pub fn grant(&self, id: &str, consent: &Consent) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
if plugin.invalid {
return Err(IdeError::invalid("the plugin's manifest is invalid"));
}
@@ -1066,7 +950,6 @@ impl PluginHost {
pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
@@ -1105,13 +988,6 @@ impl PluginHost {
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
}
fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> {
if install::uninstall_pending(&self.plugins_dir, id)? {
return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string()));
}
Ok(())
}
pub fn uninstall(&self, id: &str) -> IdeResult<()> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
@@ -1130,65 +1006,21 @@ impl PluginHost {
.flatten()
.filter_map(|s| s.get("name")?.as_str().map(str::to_owned))
.collect();
let mut pending = install::PendingUninstall {
id: id.to_owned(),
name: plugin.manifest().name.clone(),
version: plugin.manifest().version.clone(),
flat: plugin.dir() == self.plugins_dir.join(id),
secrets: secrets.clone(),
error: None,
};
// A broken manifest has no trusted identity to journal; keep its existing removal path.
if !plugin.invalid {
install::remember_uninstall(&self.plugins_dir, &pending, plugin.dir())?;
}
// Retain registration for retry, but prevent concurrent on-demand activation.
self.unload(id);
{
let _runtime = plugin.runtime.lock();
let mut meta = plugin.meta.lock();
meta.uninstalling = true;
meta.status = PluginStatus::Disabled;
}
self.stop(&plugin);
let cleanup = || -> std::io::Result<()> {
install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?;
install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?;
install::check_not_symlink(&self.shared.data_dir.join(id))?;
for file in ["secrets.json", "secrets.tmp"] {
install::check_not_symlink(&self.shared.data_dir.join(id).join(file))?;
}
let store = self.shared.secrets.read().clone();
for name in &secrets {
if let Err(e) = store.set(id, name, None) {
return Err(std::io::Error::other(format!("cannot delete secret {name:?}: {e}")));
tracing::warn!(plugin = %id, "cannot delete secret {name:?}: {e}");
}
}
// Keep the installed package and registration available until cleanup succeeds.
let data = self.shared.data_dir.join(id);
match std::fs::remove_dir_all(&data) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !data.exists() => {}
Err(e) => return Err(e),
}
// Every version (`<id>/<version>`), or the flat directory of an older install.
let target = self.plugins_dir.join(id);
match std::fs::remove_dir_all(&target) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !target.exists() => Ok(()),
result => result,
}
};
if let Err(e) = cleanup() {
pending.error = Some(e.to_string());
if !plugin.invalid
&& let Err(record_error) = install::remember_uninstall(&self.plugins_dir, &pending, plugin.dir())
{
tracing::warn!(plugin = %id, "cannot save uninstall error: {record_error}");
}
self.set_failed(&plugin, e.to_string());
self.changed();
return Err(e.into());
}
// Every version (`<id>/<version>`), or the flat directory of an older install.
let root = self.plugins_dir.join(id);
std::fs::remove_dir_all(if root.is_dir() { root } else { plugin.dir().to_path_buf() })?;
self.updates.lock().remove(id);
// Its storage goes with it.
let _ = std::fs::remove_dir_all(self.shared.data_dir.join(id));
{
let mut ledger = self.ledger.lock();
ledger.remove(&plugin.subject());
@@ -1202,12 +1034,6 @@ impl PluginHost {
}
}
self.save_state();
if let Err(e) = install::forget_uninstall(&self.plugins_dir, id) {
self.set_failed(&plugin, e.to_string());
self.changed();
return Err(e.into());
}
self.unload(id);
self.changed();
Ok(())
}
@@ -1254,7 +1080,8 @@ impl PluginHost {
})
}
/// The detail tabs of item `key` of a Services or `detail = true` view.
/// The Services pane of node `key` of the plugin's `location = "services"`
/// view `view`.
pub fn service_detail(
&self,
id: &str,
@@ -1262,9 +1089,9 @@ impl PluginHost {
key: &str,
invocation: Invocation,
) -> IdeResult<Vec<ide_plugin::ServiceTab>> {
self.detail_view(id, view)?;
self.services_view(id, view)?;
let (view, key) = (view.to_owned(), key.to_owned());
self.code_call(id, invocation, "rendering the item's detail pane", move |rt, inv| {
self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| {
rt.service_detail(&view, &key, inv)
})
}
@@ -1320,16 +1147,16 @@ impl PluginHost {
if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) }
}
fn detail_view(&self, id: &str, view: &str) -> IdeResult<()> {
let detail = self.ui.items(ui_points::VIEWS, Some(id)).iter().any(|i| {
i.key == view
&& (i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION)
|| i.decl["detail"].as_bool() == Some(true))
});
if detail {
fn services_view(&self, id: &str, view: &str) -> IdeResult<()> {
let services = self
.ui
.items(ui_points::VIEWS, Some(id))
.iter()
.any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION));
if services {
Ok(())
} else {
Err(IdeError::not_found(format!("plugin {id:?} declares no detail view {view:?}")))
Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}")))
}
}
@@ -1522,12 +1349,11 @@ impl PluginHost {
self.shared.reopen_client(label);
}
/// Window `label` closed: cancels its prompts, drops its context keys and queued
/// events, and ignores context writes of commands still running for it.
/// Window `label` closed: drops its context keys and queued events, and ignores
/// context writes of commands still running for it.
pub fn forget_client(&self, label: &str) {
self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label));
self.shared.forget_client(label);
self.forget_web_ptys(label);
}
/// The user cancelled a plugin task (`task_id` from `TaskStarted`).
@@ -1609,7 +1435,7 @@ impl PluginHost {
let mut out = BTreeSet::new();
for p in self.plugins.read().values() {
for kind in EventKind::ALL {
if Self::wants(p, kind, subs.kinds.get(p.id())) {
if self.wants(p, kind, subs.get(p.id())) {
out.insert(kind);
}
}
@@ -1619,7 +1445,7 @@ impl PluginHost {
/// Whether `plugin` gets `kind` events: subscribed at run time or with
/// `on-event:<kind>`, the capability granted, and able to run.
fn wants(plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet<EventKind>>) -> bool {
fn wants(&self, plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet<EventKind>>) -> bool {
let declared =
plugin.manifest().activation.iter().any(|a| matches!(a, ActivationEvent::Event(k) if k == kind.name()));
if !declared && !runtime.is_some_and(|s| s.contains(&kind)) {
@@ -1633,15 +1459,6 @@ impl PluginHost {
/// Queues workbench events from window `client` for the plugins that want
/// them; [`flush_events`](Self::flush_events) delivers them in batches.
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
self.post_events_from(client, events, None);
}
fn post_events_from(
&self,
client: Option<&str>,
events: Vec<WorkbenchEvent>,
source: Option<files_changed::Source>,
) {
if events.is_empty() {
return;
}
@@ -1649,60 +1466,31 @@ impl PluginHost {
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
let now = Instant::now();
let mut queues = self.queues.lock();
let mut events = events;
files_changed::retain_live(&mut events, source.as_ref());
for p in plugins {
let wanted: Vec<WorkbenchEvent> = events
.iter()
.filter(|e| Self::wants(&p, e.kind(), subs.kinds.get(p.id())))
.filter(|e| {
!matches!(e, WorkbenchEvent::FilesChanged(_))
|| p.dev_root
.as_ref()
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
})
.cloned()
.collect();
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
let wanted: Vec<WorkbenchEvent> =
events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect();
if wanted.is_empty() {
continue;
}
let queue = queues.entry((p.id().to_owned(), client.map(str::to_owned))).or_insert_with(|| EventQueue {
since: now,
events: Vec::new(),
files_source: None,
});
if let Some(source) = &source {
if !queue.files_source.as_ref().is_some_and(|old| old.same(source)) {
queue.events.retain(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)));
}
queue.files_source = Some(source.clone());
}
files_changed::append(&mut queue.events, wanted);
let queue = queues
.entry((p.id().to_owned(), client.map(str::to_owned)))
.or_insert_with(|| EventQueue { since: now, events: Vec::new() });
queue.events.extend(wanted);
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
let mut remaining = excess;
queue.events.retain(|event| {
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
remaining -= 1;
false
} else {
true
}
});
queue.events.drain(..excess);
}
}
/// Delivers queued events: to running plugins right away, to idle ones
/// (which must be activated) after [`IDLE_BATCH`], so frequent events
/// don't keep waking them. Filesystem events are already debounced by
/// the watcher and bypass that delay. Call it a few times a second.
/// don't keep waking them. Call it a few times a second.
pub fn flush_events(&self) {
self.flush_events_at(Instant::now());
}
#[doc(hidden)]
pub fn flush_events_at(&self, now: Instant) {
let due: Vec<(QueueKey, EventQueue)> = {
let due: Vec<(QueueKey, Vec<WorkbenchEvent>)> = {
let mut queues = self.queues.lock();
let plugins = self.plugins.read();
let keys: Vec<_> = queues
@@ -1713,17 +1501,15 @@ impl PluginHost {
running
|| now.saturating_duration_since(q.since) >= IDLE_BATCH
|| q.events.len() >= MAX_QUEUED_EVENTS / 2
|| q.events
.iter()
.any(|e| matches!(e, WorkbenchEvent::FilesChanged(_) | WorkbenchEvent::Scheduled))
|| q.events.contains(&WorkbenchEvent::Scheduled)
})
.map(|(k, _)| k.clone())
.collect();
keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q))).collect()
keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q.events))).collect()
};
let mut changed = false;
for ((id, client), queue) in due {
changed |= self.deliver(&id, client, queue);
for ((id, client), events) in due {
changed |= self.deliver(&id, client, events);
}
// Batches that crashed since.
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
@@ -1756,52 +1542,12 @@ impl PluginHost {
}
}
fn filter_delivery_events(
shared: &api::Shared,
plugin: &LoadedPlugin,
client: Option<&str>,
events: &[WorkbenchEvent],
subs: &event_paths::Subscriptions,
) -> Vec<WorkbenchEvent> {
Self::filter_path_events(shared, plugin, client, events, subs)
.into_iter()
.filter(|event| {
!matches!(
event,
WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_) | WorkbenchEvent::FilesChanged(_)
) || Self::wants(plugin, event.kind(), subs.kinds.get(plugin.id()))
})
.collect()
}
/// Hands one batch to the plugin's `event-handler.handle` without waiting
/// for it. A newer batch of the same kinds cooperatively cancels the one
/// being handled (a save while the previous save is still being checked).
/// Filesystem deltas must all run: a newer delta cannot replace an older one.
/// Returns whether the plugin was activated.
fn deliver(&self, id: &str, client: Option<String>, mut queue: EventQueue) -> bool {
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
if queue.events.is_empty() {
return false;
}
if queue.events.iter().any(|e| matches!(e, WorkbenchEvent::FilesChanged(_)))
&& queue.events.iter().any(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)))
{
let (files, other) = queue.events.into_iter().partition(|e| matches!(e, WorkbenchEvent::FilesChanged(_)));
let changed =
self.deliver(id, client.clone(), EventQueue { since: queue.since, events: other, files_source: None });
return self.deliver(
id,
client,
EventQueue { since: queue.since, events: files, files_source: queue.files_source },
) | changed;
}
fn deliver(&self, id: &str, client: Option<String>, events: Vec<WorkbenchEvent>) -> bool {
let Ok(plugin) = self.get(id) else { return false };
let subs = self.shared.subscriptions.lock().clone();
let eligible = Self::filter_delivery_events(&self.shared, &plugin, client.as_deref(), &queue.events, &subs);
if eligible.is_empty() {
return false;
}
if !plugin.meta.lock().pending.is_empty() {
return false;
}
@@ -1811,94 +1557,21 @@ impl PluginHost {
client: client.clone(),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
};
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
let filesystem = kinds.contains(&EventKind::FilesChanged);
let mailbox_key = (client.clone(), invocation.workspace_root.clone());
let mut ordinary = None;
let token = Arc::new(());
let kinds: BTreeSet<EventKind> = events.iter().map(WorkbenchEvent::kind).collect();
{
let mut batches = plugin.batches.lock();
if batches.running.as_ref() == Some(&kinds)
&& !kinds.contains(&EventKind::Scheduled)
&& !kinds.contains(&EventKind::FilesChanged)
{
if batches.running.as_ref() == Some(&kinds) && !kinds.contains(&EventKind::Scheduled) {
actor.cancel_soft();
}
if filesystem {
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
if queue.events.is_empty() {
return activated;
}
if let Some(pending) = batches.files_pending.get_mut(&mailbox_key)
&& pending.actor.upgrade().is_some_and(|owner| Arc::ptr_eq(&owner, &actor))
{
let pending = &mut pending.queue;
if queue
.files_source
.as_ref()
.is_some_and(|source| !pending.files_source.as_ref().is_some_and(|old| old.same(source)))
{
pending.events.clear();
pending.files_source = queue.files_source.clone();
}
files_changed::append(&mut pending.events, queue.events);
return activated;
}
batches.files_pending.insert(
mailbox_key.clone(),
PendingFiles { queue, actor: Arc::downgrade(&actor), token: token.clone() },
);
} else {
ordinary = Some(queue);
}
batches.pending += 1;
}
let started = Instant::now();
let start = plugin.batches.clone();
let ticket = BatchTicket {
batches: plugin.batches.clone(),
files_key: filesystem.then(|| mailbox_key.clone()),
token: token.clone(),
};
let (start, finish) = (plugin.batches.clone(), plugin.batches.clone());
let done_plugin = plugin.clone();
let delivery_plugin = plugin.clone();
let shared = self.shared.clone();
actor.post(
move |rt| {
let _ticket = ticket;
let mut queue = {
let mut batches = start.lock();
let queue = if filesystem {
if !batches.files_pending.get(&mailbox_key).is_some_and(|p| Arc::ptr_eq(&p.token, &token)) {
return Ok(());
}
batches.files_pending.remove(&mailbox_key).expect("owned filesystem batch").queue
} else {
ordinary.expect("ordinary event batch")
};
batches.running = Some(kinds);
batches.running_token = Some(token);
queue
};
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
if filesystem
&& invocation.workspace_root != invocation.client.as_deref().and_then(|c| shared.project_of(c))
{
return Ok(());
}
let subs = shared.subscriptions.lock().clone();
let events = Self::filter_delivery_events(
&shared,
&delivery_plugin,
invocation.client.as_deref(),
&queue.events,
&subs,
);
if events.is_empty() {
return Ok(());
}
start.lock().running = Some(kinds);
rt.handle_events(events, invocation)
},
move |result| {
@@ -1915,6 +1588,9 @@ impl PluginHost {
}
_ => {}
}
let mut batches = finish.lock();
batches.pending = batches.pending.saturating_sub(1);
batches.running = None;
},
);
activated
@@ -2057,7 +1733,6 @@ impl PluginHost {
let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect();
for id in self.plugins.read().keys() {
self.shared.block_prompts(id);
self.stop_web_ptys(id);
}
for a in &actors {
a.stop(false);
@@ -2065,7 +1740,6 @@ impl PluginHost {
for a in actors {
a.stop(true);
}
self.shared.finish_process_shutdowns();
}
/// Periodic upkeep (every few seconds): reports crashes nobody waited
@@ -2112,7 +1786,7 @@ impl PluginHost {
self.queues
.lock()
.entry((p.id().to_owned(), None))
.or_insert_with(|| EventQueue { since: now, events: Vec::new(), files_source: None })
.or_insert_with(|| EventQueue { since: now, events: Vec::new() })
.events
.push(WorkbenchEvent::Scheduled);
}
@@ -2180,17 +1854,7 @@ impl PluginHost {
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
let (manifest, error) = match Manifest::load(dir) {
Ok(m) => (m, None),
Err(e) => {
let mut manifest = placeholder_manifest(dir);
// Installed directory names retain identity when discovery metadata is damaged.
if source == PluginSource::Installed
&& let Ok(relative) = dir.strip_prefix(&self.plugins_dir)
&& let Some(name) = relative.components().next().and_then(|p| p.as_os_str().to_str())
{
manifest.id = name.to_owned();
}
(manifest, Some(e))
}
Err(e) => (placeholder_manifest(dir), Some(e)),
};
let invalid = error.is_some();
let id = manifest.id.clone();
@@ -2240,27 +1904,10 @@ impl PluginHost {
self.stop(&plugin);
}
fn check_not_uninstalling(&self, plugin: &LoadedPlugin) -> IdeResult<()> {
if plugin.meta.lock().uninstalling {
return Err(IdeError::unavailable("Uninstall incomplete; retry Uninstall to finish cleanup."));
}
if plugin.source == PluginSource::Installed {
self.check_uninstall_pending(plugin.id())?;
}
Ok(())
}
/// Validates, checks grants and commits the contributions; activates code
/// whose activation event already fired.
fn start(&self, plugin: &Arc<LoadedPlugin>) {
if let Err(e) = self.check_not_uninstalling(plugin) {
if !plugin.meta.lock().uninstalling {
self.set_failed(plugin, e.message);
}
return;
}
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
if plugin.source == PluginSource::Installed
&& let Some(reason) = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir())
{
@@ -2318,7 +1965,6 @@ impl PluginHost {
/// first if needed. Returns whether it was activated now.
fn ensure_active(&self, plugin: &Arc<LoadedPlugin>, client: Option<&str>) -> IdeResult<(Arc<Actor>, bool)> {
let mut runtime = plugin.runtime.lock();
self.check_not_uninstalling(plugin)?;
if let Some(actor) = runtime.as_ref() {
if actor.is_alive() {
return Ok((actor.clone(), false));
@@ -2427,7 +2073,6 @@ impl PluginHost {
}
let Some(error) = actor.take_crash() else { return };
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
if error.starts_with(CANCELLED) {
// Cancelled on request: restart on next use, no backoff.
let mut meta = plugin.meta.lock();
@@ -2444,7 +2089,6 @@ impl PluginHost {
/// A trap dropped the plugin's instance: restart it on next use after a
/// backoff, or stop it for the session after too many crashes in a row.
fn crashed(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
self.clear_provider_diagnostics(plugin.id());
let failures = {
let mut meta = plugin.meta.lock();
meta.failures += 1;
@@ -2474,7 +2118,6 @@ impl PluginHost {
level: NotificationLevel::Warning,
title: format!("Plugin \u{201c}{}\u{201d} crashed", plugin.manifest().name),
body: format!("{error}\nIt restarts the next time it is needed."),
actions: Vec::new(),
},
);
}
@@ -2491,8 +2134,6 @@ impl PluginHost {
*runtime = None;
}
actor.stop(false);
// Its handles go with its instance; an idle reclaim is one.
self.shared.kill_processes(plugin.id());
let mut meta = plugin.meta.lock();
if meta.status == PluginStatus::Active {
meta.status = PluginStatus::Inactive;
@@ -2503,36 +2144,28 @@ impl PluginHost {
}
fn stop(&self, plugin: &LoadedPlugin) {
self.stop_web_ptys(plugin.id());
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
// A call waiting on the user would keep the join below waiting.
self.shared.block_prompts(plugin.id());
if let Some(actor) = plugin.runtime.lock().take() {
actor.stop(true);
}
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
self.shared.clear_plugin(plugin.id(), true);
self.queues.lock().retain(|(id, _), _| id != plugin.id());
}
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
tracing::warn!(plugin = %plugin.id(), "{error}");
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Failed;
meta.error = Some(error);
drop(meta);
self.stop_web_ptys(plugin.id());
}
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}");
if let Some(actor) = plugin.runtime.lock().take() {
actor.stop(false);
@@ -2546,7 +2179,6 @@ impl PluginHost {
level: NotificationLevel::Error,
title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest().name),
body: error.to_owned(),
actions: Vec::new(),
},
);
self.changed();
@@ -2891,70 +2523,6 @@ impl ide_language::ExternalSyntax for PluginSyntax {
mod tests {
use super::*;
#[test]
fn host_panics_stop_actor_and_reply_on_every_call_path() {
let tmp = tempfile::tempdir().unwrap();
let host =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
let plugin = host.get("test.diagnostics").unwrap();
for mode in 0..5 {
let (actor, _) = host.ensure_active(&plugin, None).unwrap();
let cancel = ide_core::CancellationToken::new();
let next_actor = actor.clone();
let (queued_tx, queued_rx) = std::sync::mpsc::channel();
let panic = move |_: &mut Runtime| -> Result<(), CallError> {
let pending = next_actor.submit(None, |_| Ok("must not run")).unwrap();
queued_tx.send(pending).unwrap();
panic!("host call panic")
};
let result = match mode {
0 => actor.call(None, panic),
1 => actor.call_cancellable(&cancel, Duration::ZERO, panic),
2 => actor.call_edit_cancellable(&cancel, Duration::ZERO, Duration::from_secs(5), panic),
_ => {
let (tx, rx) = std::sync::mpsc::channel();
if mode == 3 {
actor.post(panic, move |result| {
let _ = tx.send(result);
});
} else {
actor.post(
|_| Ok(()),
move |result| {
let _ = tx.send(result);
panic!("completion callback panic");
},
);
}
rx.recv_timeout(Duration::from_secs(5)).expect("actor must reply")
}
};
if mode < 4 {
let queued = queued_rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(matches!(queued.wait(), Err(ActorError::Stopped)), "poisoned runtime must not run queued work");
}
actor.stop(true);
if mode < 4 {
assert!(
matches!(result, Err(ActorError::Call(CallError::Trap(ref message))) if message.contains("host call panic")),
"{result:?}"
);
}
assert!(!actor.is_alive());
assert!(!actor.is_busy());
assert!(matches!(actor.call(None, |_| Ok(())), Err(ActorError::Stopped)));
let crash = actor.take_crash().expect("supervisor must see the panic");
assert!(crash.contains(if mode == 4 { "completion callback panic" } else { "host call panic" }));
assert!(actor.take_crash().is_none());
cancel.cancel();
// Re-enable to reset the supervisor before testing the next call path.
host.set_enabled("test.diagnostics", false).unwrap();
host.set_enabled("test.diagnostics", true).unwrap();
}
}
#[test]
fn globs() {
assert!(glob_match("**/*Mapper.xml", "src/main/UserMapper.xml"));
+1 -89
View File
@@ -28,7 +28,6 @@ pub use ide_plugin::indexing::*;
struct File {
version: Option<u32>,
scope: String,
hash: blake3::Hash,
edges: Edges,
}
#[derive(Default)]
@@ -62,11 +61,6 @@ impl Graph {
}
targets
}
fn scoped_targets(&self, key: &str, scope: &str) -> Vec<&Location> {
let targets = self.targets(key);
let local = targets.iter().any(|t| self.files.get(&t.path.0).is_some_and(|f| f.scope == scope));
targets.into_iter().filter(|t| !local || self.files.get(&t.path.0).is_some_and(|f| f.scope == scope)).collect()
}
fn remove(&mut self, path: &Path) {
if let Some(file) = self.files.remove(path) {
self.edges -= file.edges.definitions.len()
@@ -309,7 +303,7 @@ impl PluginHost {
{
return Err(IdeError::unavailable("navigation association limit reached"));
}
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), hash: snapshot_hash(text), edges });
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
}
Ok(())
}
@@ -371,70 +365,6 @@ impl PluginHost {
}
result.filter(|r| !r.targets.is_empty())
}
/// Definitions and references of each indexed key under the caret. Markers
/// are navigation affordances, not name anchors, and never participate.
pub fn indexed_symbol_at(&self, root: &Path, path: &Path, pos: Pos, client: Option<&str>) -> Option<IndexedSymbol> {
if !(self.project_policy.read())(root) {
return None;
}
let plugins = self.plugins.read();
let index = self.navigation.lock();
let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?;
let mut result: Option<IndexedSymbol> = None;
for (id, graph) in graphs {
let Some(plugin) = plugins.get(id) else { continue };
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
continue;
}
let Some(source) = graph.files.get(path) else { continue };
let anchors: Vec<_> = source
.edges
.definitions
.iter()
.chain(&source.edges.references)
.filter(|anchor| contains(anchor.range, pos))
.collect();
let Some(primary) = anchors.first() else { continue };
let keys: std::collections::HashSet<_> = anchors.iter().map(|anchor| anchor.key.as_str()).collect();
let targets: std::collections::HashSet<_> =
keys.iter().flat_map(|key| graph.scoped_targets(key, &source.scope)).collect();
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
for (path, file) in &graph.files {
let mut ranges = std::collections::BTreeSet::new();
for candidate in &file.edges.definitions {
if targets.contains(&Location::file(path.clone(), candidate.range))
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
{
ranges.insert(candidate.range);
}
}
let mut matches = HashMap::new();
for candidate in &file.edges.references {
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
resolved.iter().any(|target| targets.contains(target))
|| (targets.is_empty()
&& resolved.is_empty()
&& file.scope == source.scope
&& keys.contains(candidate.key.as_str()))
});
if same {
ranges.insert(candidate.range);
}
}
if !ranges.is_empty() {
answer.files.push(IndexedAnchors {
path: path.clone(),
version: file.version,
hash: file.hash,
ranges: ranges.into_iter().collect(),
});
}
}
}
result
}
/// Gutter markers of visible files. Uses only existing graph entries; never executes WASM.
pub fn navigation_markers(
&self,
@@ -493,24 +423,6 @@ impl PluginHost {
}
}
#[derive(Clone)]
pub struct IndexedSymbol {
pub range: Range,
pub files: Vec<IndexedAnchors>,
}
#[derive(Clone)]
pub struct IndexedAnchors {
pub path: PathBuf,
pub version: Option<u32>,
pub hash: blake3::Hash,
pub ranges: Vec<Range>,
}
pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
blake3::hash(ide_text::normalize_line_endings(text).as_bytes())
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
-3
View File
@@ -59,7 +59,6 @@ pub fn register_builtin_points(
registry.register(Arc::new(super::navigation::IndexersPoint))?;
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
use super::ui_points as p;
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
@@ -67,10 +66,8 @@ pub fn register_builtin_points(
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
registry.register(Arc::new(UiPoint::new(p::MARKER_ICONS, ui.clone(), p::marker_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
registry.register(Arc::new(UiPoint::new(ide_extension::LOCALES_POINT, ui.clone(), p::locales)))?;
Ok(())
}
+12 -142
View File
@@ -22,7 +22,6 @@
use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -40,8 +39,6 @@ pub enum ProviderKind {
LineMarkers,
SemanticTokens,
Hover,
InlayHints,
Diagnostics,
}
#[derive(Clone, Debug, Deserialize)]
@@ -75,26 +72,13 @@ impl Providers {
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
}
/// One coherent declaration snapshot, preserving the first match per plugin.
fn matching<T: FromIterator<(String, u64)>>(&self, kind: ProviderKind, language: &str) -> T {
let items = self.items.read();
let mut seen = HashSet::new();
items
.iter()
.filter(|(_, plugin, kinds, languages)| {
kinds.contains(&kind)
&& languages.iter().any(|l| l == "*" || l == language)
&& seen.insert(plugin.as_str())
})
.map(|(seq, plugin, ..)| (plugin.clone(), *seq))
.collect()
}
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
self.matching(ProviderKind::Diagnostics, language)
let mut out: Vec<String> = Vec::new();
for (_, plugin, kinds, languages) in self.items.read().iter() {
if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) {
out.push(plugin.clone());
}
}
out
}
/// Whether any plugin provides `kind` (for any language).
@@ -133,15 +117,9 @@ impl ExtensionPoint for ProvidersPoint {
if d.kinds.contains(&ProviderKind::SemanticTokens) {
pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?;
}
if d.kinds.contains(&ProviderKind::Diagnostics) {
pkg.manifest.needs_api("contributes.providers: diagnostics", 3)?;
}
if d.kinds.contains(&ProviderKind::Hover) {
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
}
if d.kinds.contains(&ProviderKind::InlayHints) {
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
@@ -187,11 +165,11 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
// ---- dispatch ---------------------------------------------------------------
use super::{LoadedPlugin, PluginEvent, PluginHost, PluginStatus};
use super::{LoadedPlugin, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
PluginInlayHint, ProviderDocument, Runtime, SemanticToken,
ProviderDocument, Runtime, SemanticToken,
};
use std::sync::Weak;
use std::time::{Duration, Instant};
@@ -220,7 +198,7 @@ pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
impl PluginHost {
/// Usable plugins declaring `kind` for `language`.
pub(super) fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
self.providers
.plugins_for(kind, language)
.into_iter()
@@ -333,21 +311,14 @@ impl PluginHost {
plugin_id: &str,
doc: &ProviderDocument,
action: CodeAction,
invocation: ide_plugin::Invocation,
) -> ide_core::IdeResult<CodeAction> {
let d = Arc::new(doc.clone());
let timeout = self.limits.timeout;
self.code_call(plugin_id, invocation, "resolving the code action", move |rt, inv| {
rt.resolve_code_action(&d, &action, inv, timeout)
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
rt.resolve_code_action(&d, &action, timeout)
})
}
/// Shows `path` in window `client` after a code action of `plugin_id` was applied.
pub fn open_file(&self, plugin_id: &str, client: &str, path: ide_core::IpcPath) {
let event = PluginEvent::OpenLocation { plugin_id: plugin_id.to_owned(), path, line: 0, column: 0 };
self.emit(Some(client), event);
}
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
@@ -378,59 +349,6 @@ impl PluginHost {
.collect()
}
pub fn provide_diagnostics(
&self,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
self.provide_project_diagnostics(None, doc, cancel)
}
pub(super) fn diagnostic_plugins(
&self,
project: Option<&std::path::Path>,
language: &str,
) -> Vec<Arc<LoadedPlugin>> {
self.provider_plugins(ProviderKind::Diagnostics, language)
.into_iter()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.collect()
}
pub(super) fn provide_project_diagnostics(
&self,
project: Option<&std::path::Path>,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
std::thread::scope(|scope| {
let calls: Vec<_> = self
.diagnostic_plugins(project, &doc.language)
.into_iter()
.map(|p| {
scope.spawn(move || {
if cancel.is_cancelled() {
return None;
}
let (actor, activated) = self.ensure_active(&p, None).ok()?;
if activated {
self.changed();
}
let (d, timeout) = (doc.clone(), self.limits.edit_hard);
let started = Instant::now();
let result =
actor.call_edit_cancellable(cancel, CANCEL_GRACE, self.limits.edit_soft, move |rt| {
rt.provide_diagnostics(&d, timeout)
});
let items = self.finish_provider_call(&p, &actor, started, result)?;
(!cancel.is_cancelled()).then(|| (p.id().to_owned(), items))
})
})
.collect();
calls.into_iter().filter_map(|call| call.join().ok().flatten()).collect()
})
}
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
@@ -439,16 +357,6 @@ impl PluginHost {
.collect()
}
pub fn provide_inlay_hints(&self, doc: &ProviderDocument, range: ide_core::Range) -> Vec<PluginInlayHint> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::InlayHints, &doc.language, move |rt, timeout| {
rt.provide_inlay_hints(&d, range, timeout)
})
.into_iter()
.flat_map(|(_, hints)| hints)
.collect()
}
/// Ghost text from the first plugin that has some. Not bound by the
/// edit-path soft deadline (it may need the network) but by `cancel`:
/// the editor cancels a request as soon as the user types on.
@@ -477,41 +385,3 @@ impl PluginHost {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_snapshots_preserve_order_first_generation_and_language_matching() {
let providers = Arc::new(Providers::default());
let declare = |plugin: &str, kind, language: &str| {
providers.add(plugin.into(), Decl { kinds: vec![kind], languages: vec![language.into()] })
};
let mut declarations = Vec::new();
declarations.push(declare("unrelated", ProviderKind::Hover, "*"));
declarations.push(declare("other-language", ProviderKind::Diagnostics, "rust"));
for i in 0..256 {
let plugin = format!("plugin-{i}");
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "text"));
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "*"));
}
let expected: Vec<_> = (0..256).map(|i| format!("plugin-{i}")).collect();
assert_eq!(providers.plugins_for(ProviderKind::Diagnostics, "text"), expected);
let before = providers.diagnostic_generations("text");
assert_eq!(before.len(), 256);
assert_eq!(before["plugin-0"], 2);
assert_eq!(providers.diagnostic_generations("rust").len(), 257);
assert_eq!(providers.plugins_for(ProviderKind::Hover, "text"), ["unrelated"]);
// Removing the first matching declaration must invalidate its generation,
// even while a second declaration still provides the same language.
drop(declarations.remove(2));
let after = providers.diagnostic_generations("text");
assert_eq!(after["plugin-0"], 3);
assert_ne!(before["plugin-0"], after["plugin-0"]);
drop(declarations);
assert!(providers.diagnostic_generations("text").is_empty());
let _reloaded = declare("plugin-0", ProviderKind::Diagnostics, "text");
assert_ne!(providers.diagnostic_generations("text")["plugin-0"], after["plugin-0"]);
}
}
+2 -21
View File
@@ -14,8 +14,8 @@
//! The commands a window needs at run time (`plugin_run_command`,
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
//! `plugin_line_markers`, `plugin_set_ui_locale`, `plugin_view_render`, `plugin_view_event`,
//! `plugin_service_detail`, `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
//! `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
//! carry the desktop commands' names and arguments, so a remote window's UI
//! routes them here unchanged. Plugin results come back as `plugin`
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
@@ -31,7 +31,6 @@ use ide_core::{IdeError, IdeResult};
use ide_extension::{MANIFEST_FILE, ledger};
use ide_jsonrpc::Peer;
use ide_plugin::{Invocation, Limits};
use ide_workspace::settings::SettingsScope;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -118,15 +117,12 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
| PluginEvent::OpenLogTab { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
| PluginEvent::EndpointsChanged { .. }
)
}
@@ -378,26 +374,16 @@ impl RemotePlugins {
let arg: Option<String> = c.arg("arg")?;
let editor: Option<PluginEditor> = c.arg("editor")?;
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
let invocation = Invocation {
client: Some(c.window.clone()),
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
selected_paths: paths.unwrap_or_default(),
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
});
let h = host.clone();
d.add("plugin_set_ui_locale", move |c| {
let h = h.clone();
async move {
alive(&h)?.set_ui_locale(&c.arg::<String>("tag")?);
Ok(())
}
});
let h = host.clone();
d.add("plugin_post_events", move |c| {
let h = h.clone();
async move {
@@ -572,11 +558,6 @@ impl RemotePlugins {
super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await
}
});
let h = host.clone();
d.add("plugin_endpoints", move |c| {
let h = h.clone();
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
});
d.add("plugin_line_markers", |c| async move {
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
});
+1 -19
View File
@@ -168,22 +168,11 @@ pub enum PluginEvent {
CommandsChanged,
/// Cached document associations were disposed (reload, disable or failure).
DocumentIndexesChanged,
/// The routes `plugin_id` reports for the project `root` may have changed:
/// ask `plugin_endpoints` again.
#[serde(rename_all = "camelCase")]
EndpointsChanged { plugin_id: String, root: ide_core::IpcPath },
/// The marketplace offers newer versions of installed plugins (announced
/// once per set of versions).
UpdatesAvailable { updates: Vec<PluginUpdate> },
#[serde(rename_all = "camelCase")]
Notification {
plugin_id: String,
level: NotificationLevel,
title: String,
body: String,
/// Buttons running the plugin's own commands (`ui.notify`).
actions: Vec<ide_plugin::NotificationAction>,
},
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
/// A plugin's status bar item was set (`item`) or removed.
#[serde(rename_all = "camelCase")]
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
@@ -224,10 +213,6 @@ pub enum PluginEvent {
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Open (or focus) the log tab `key` following `log` (`views.open-log-tab`);
/// it starts the program with `plugin_log_tab_spawn`.
#[serde(rename_all = "camelCase")]
OpenLogTab { plugin_id: String, key: String, title: String, log: ide_plugin::ServiceLog },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
@@ -237,9 +222,6 @@ pub enum PluginEvent {
/// Open `url` (`http(s)://`) in the system browser.
#[serde(rename_all = "camelCase")]
OpenUrl { plugin_id: String, url: String },
/// A window command/event requests the normal File > Open flow.
#[serde(rename_all = "camelCase")]
OpenProject { plugin_id: String, path: String, new_window: bool },
/// A command waits for the user's answer: show `prompt`, then report it with
/// `plugin_answer_prompt(prompt_id, …)`.
#[serde(rename_all = "camelCase")]
+16 -490
View File
@@ -53,14 +53,14 @@
//! path = "templates/drawing.excalidraw"
//! ```
use ide_extension::{LOCALES_DIR, Package, UiDecl, decode, validate_item_id, validate_when};
use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::collections::BTreeSet;
use std::path::PathBuf;
/// A view whose tree shows in the Services tool window; the plugin's
/// `detail-provider` renders the selected node's pane.
/// `service-provider` renders the selected node's pane.
pub const SERVICES_LOCATION: &str = "services";
pub const MENUS: &str = "menus";
pub const KEYMAPS: &str = "keymaps";
@@ -69,7 +69,6 @@ pub const SETTINGS: &str = "settings";
pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const MARKER_ICONS: &str = "markerIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
@@ -123,10 +122,6 @@ struct MenuDecl {
when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
view: Option<String>,
/// Groups the plugin's entries of one context menu that name the same
/// submenu under it.
#[serde(default, skip_serializing_if = "Option::is_none")]
submenu: Option<String>,
}
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
@@ -141,13 +136,6 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
if let Some(submenu) = &d.submenu {
if !d.menu.ends_with(".context") {
return Err(format!("contributes.menus: `submenu` is only for context menus, not {:?}", d.menu));
}
title(MENUS, submenu)?;
pkg.manifest.needs_api("contributes.menus: submenu", 3)?;
}
let key = match (d.menu == VIEW_ITEM_MENU, &d.view) {
(true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")),
(true, Some(v)) => {
@@ -283,60 +271,12 @@ pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, St
struct SettingsDecl {
#[serde(default)]
title: Option<String>,
schema: Option<PathBuf>,
#[serde(default)]
pages: Vec<SettingsPage>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SettingsPage {
id: String,
title: String,
schema: PathBuf,
#[serde(default)]
actions: Vec<SettingsAction>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SettingsAction {
title: String,
command: String,
}
#[derive(Debug, Deserialize)]
struct SchemaFile {
#[serde(deserialize_with = "ordered_properties")]
properties: Vec<(String, Box<serde_json::value::RawValue>)>,
}
// Preserve schema field order locally, without changing serde_json maps throughout the host.
fn ordered_properties<'de, D>(deserializer: D) -> Result<Vec<(String, Box<serde_json::value::RawValue>)>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Properties;
impl<'de> serde::de::Visitor<'de> for Properties {
type Value = Vec<(String, Box<serde_json::value::RawValue>)>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an object of setting schemas")
}
fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
let mut fields = Vec::new();
let mut keys = BTreeSet::new();
while let Some((key, value)) = map.next_entry::<String, Box<serde_json::value::RawValue>>()? {
if !keys.insert(key.clone()) {
return Err(serde::de::Error::custom(format!("duplicate setting key {key:?}")));
}
fields.push((key, value));
}
Ok(fields)
}
}
deserializer.deserialize_map(Properties)
properties: serde_json::Map<String, Value>,
}
/// One setting of a plugin.
@@ -344,7 +284,7 @@ where
#[serde(rename_all = "camelCase")]
pub struct SettingSchema {
pub key: String,
/// A scalar, top-level `array`, or `object` table row.
/// `boolean`, `string`, `number` or `integer`.
#[serde(rename = "type")]
pub kind: String,
pub title: String,
@@ -355,12 +295,6 @@ pub struct SettingSchema {
pub options: Option<Vec<String>>,
#[serde(default)]
pub multiline: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[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>,
}
impl SettingSchema {
@@ -370,13 +304,6 @@ impl SettingSchema {
"boolean" => v.is_boolean(),
"integer" => v.is_i64() || v.is_u64(),
"number" => v.is_number(),
"array" => v.as_array().is_some_and(|a| {
a.len() <= 1000 && self.items.as_ref().is_some_and(|item| a.iter().all(|v| item.accepts(v)))
}),
"object" => v.as_object().is_some_and(|o| {
o.len() == self.properties.len()
&& self.properties.iter().all(|p| o.get(&p.key).is_some_and(|v| p.accepts(v)))
}),
_ => v
.as_str()
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
@@ -384,47 +311,17 @@ impl SettingSchema {
}
}
fn setting(key: &str, raw: &serde_json::value::RawValue) -> Result<SettingSchema, String> {
setting_at(key, raw, 0)
}
fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result<SettingSchema, String> {
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
}
let value: Value = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
let o = value.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
let mut items = None;
let mut properties = Vec::new();
let default = match kind {
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
"array" if depth == 0 => {
#[derive(Deserialize)]
struct ArraySchema {
items: Box<serde_json::value::RawValue>,
}
let array: ArraySchema = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
let item = setting_at("item", &array.items, 1)?;
if item.kind != "string" && item.kind != "object" {
return Err(format!("setting {key:?}: items must be strings or objects"));
}
items = Some(Box::new(item));
o.get("default").cloned().unwrap_or_else(|| Value::Array(Vec::new()))
}
"object" if depth == 1 => {
let fields = serde_json::from_str::<SchemaFile>(raw.get()).map_err(|e| e.to_string())?.properties;
if fields.is_empty() || fields.len() > MAX_SETTINGS {
return Err(format!("setting {key:?}: table needs 1–{MAX_SETTINGS} columns"));
}
properties = fields.iter().map(|(k, v)| setting_at(k, v, 2)).collect::<Result<_, _>>()?;
o.get("default").cloned().unwrap_or_else(|| {
Value::Object(properties.iter().map(|p| (p.key.clone(), p.default.clone())).collect())
})
}
_ => return Err(format!("setting {key:?}: unsupported setting type {kind:?}")),
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
};
let options = match o.get("enum") {
None => None,
@@ -450,9 +347,6 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
default,
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
group: text("group", MAX_TITLE)?,
items,
properties,
};
if !s.accepts(&s.default) {
return Err(format!("setting {key:?}: the default does not match its type"));
@@ -471,68 +365,13 @@ pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: SettingsDecl = decode(SETTINGS, decl)?;
if d.schema.is_some() == !d.pages.is_empty() {
return Err("settings: use either `schema` or nonempty `pages`".into());
}
if d.pages.len() > 32 {
return Err("settings: at most 32 pages".into());
}
let text = pkg.read_text(&d.schema)?;
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
title_ok(&title)?;
let mut files = Vec::new();
let mut keys = BTreeSet::new();
let mut load = |path: &Path| -> Result<Vec<SettingSchema>, String> {
let properties =
parse_settings_schema(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
for p in &properties {
if !keys.insert(p.key.clone()) {
return Err(format!("settings: duplicate key {:?} across pages", p.key));
}
if p.group.is_some() || p.kind == "array" {
pkg.manifest.needs_api("settings: groups / arrays", 3)?;
}
}
if keys.len() > MAX_SETTINGS {
return Err(format!("more than {MAX_SETTINGS} settings"));
}
files.push(path.to_path_buf());
Ok(properties)
};
let mut properties = Vec::new();
let mut pages = Vec::new();
if let Some(path) = d.schema {
properties = load(&path)?;
} else {
pkg.manifest.needs_api("contributes.settings.pages", 3)?;
let mut ids = BTreeSet::new();
for p in d.pages {
validate_item_id(&p.id, "settings page id")?;
if p.id.contains('.') || !ids.insert(p.id.clone()) {
return Err(format!("settings: page id {:?} must be unique and contain no dots", p.id));
}
title_ok(&p.title)?;
if p.actions.len() > 32 {
return Err("settings: at most 32 actions per page".into());
}
for a in &p.actions {
title_ok(&a.title)?;
declared_command(pkg, SETTINGS, &a.command)?;
}
let fields = load(&p.schema)?;
properties.extend(fields.clone());
pages.push(serde_json::json!({
"id": p.id, "title": p.title, "properties": fields, "actions": p.actions,
}));
}
}
let mut decl = UiDecl::new(
"settings",
title.clone(),
serde_json::json!({
"title": title, "properties": properties, "pages": pages,
}),
);
decl.files = files;
let mut decl =
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
decl.files.push(d.schema);
Ok(decl)
}
@@ -549,16 +388,6 @@ struct ViewDecl {
icon: Option<String>,
#[serde(default = "right")]
location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
/// Show the selected tree/list item's detail-provider tabs beside it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
detail: bool,
/// `default` or `none` (tool-window web views that render their own header row).
#[serde(default, skip_serializing_if = "Option::is_none")]
header: Option<String>,
/// A web page of the package (`web/index.html`) instead of content the code renders.
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
@@ -579,15 +408,6 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: ViewDecl = decode(VIEWS, decl)?;
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
if let Some(group) = &d.group {
pkg.manifest.needs_api("contributes.views.group", 3)?;
validate_item_id(group, "view group")?;
}
if let Some(when) = &d.when {
pkg.manifest.needs_api("contributes.views.when", 3)?;
validate_when(when).map_err(|e| format!("contributes.views: {e}"))?;
}
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id));
@@ -598,24 +418,6 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id));
}
}
if d.detail {
pkg.manifest.needs_api(&format!("view {:?}: `detail`", d.id), 3)?;
if d.web.is_some() {
return Err(format!("view {:?}: `detail` needs a tree or list rendered by the plugin (no `web`)", d.id));
}
}
if let Some(header) = &d.header {
pkg.manifest.needs_api("contributes.views.header", 3)?;
if header != "default" && header != "none" {
return Err(format!("view {:?}: `header` is default or none", d.id));
}
if header == "none" && (d.web.is_none() || d.group.is_some()) {
return Err(format!(
"view {:?}: `header = \"none\"` is only for ungrouped tool-window web views (`web`)",
d.id
));
}
}
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
}
@@ -727,56 +529,17 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MarkerIconDecl {
id: String,
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
}
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.markerIcons", 3)?;
let d: MarkerIconDecl = decode(MARKER_ICONS, decl)?;
validate_item_id(&d.id, "marker icon id")?;
if d.icon.to_string_lossy().len() > 256 {
return Err("marker icon paths are up to 256 bytes".into());
}
let mut out = UiDecl::new(d.id.clone(), d.id.clone(), serde_json::json!({ "id": d.id, "icon": d.icon }));
for (path, variant) in [(Some(&d.icon), "light"), (d.icon_dark.as_ref(), "dark")] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
out.assets.push((format!("markerIcon/{}/{variant}", d.id), svg));
out.files.push(path.clone());
}
// A changed SVG must reproject even when its declared path stays the same.
let bytes = serde_json::to_vec(&out.assets).map_err(|e| e.to_string())?;
out.json["revision"] = Value::String(blake3::hash(&bytes).to_hex().to_string());
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileTemplateDecl {
id: String,
name: String,
/// Without the dot.
#[serde(default)]
extension: String,
file_name: Option<String>,
variables: Option<Vec<FileTemplateVariable>>,
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
path: PathBuf,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct FileTemplateVariable {
name: String,
prompt: String,
}
const MAX_TEMPLATE: usize = 256 << 10;
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
@@ -785,59 +548,8 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
validate_item_id(&d.id, "file template id")?;
title(FILE_TEMPLATES, &d.name)?;
let what = format!("file template {:?}", d.id);
if d.file_name.is_some() || d.variables.is_some() {
pkg.manifest.needs_api("contributes.fileTemplates: fileName / variables", 3)?;
}
if let Some(name) = &d.file_name {
let stem = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
if name.is_empty()
|| name.len() > 255
|| name.trim() != name
|| name.ends_with('.')
|| matches!(name.as_str(), "." | "..")
|| name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c))
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
&& matches!(stem.as_bytes().get(3), Some(b'1'..=b'9'))
&& stem.len() == 4)
{
return Err(format!("{what}: `fileName` must be a safe single file name"));
}
}
let mut names = std::collections::HashSet::new();
let variables = d.variables.unwrap_or_default();
if variables.len() > 32 {
return Err(format!("{what}: at most 32 variables are allowed"));
}
for variable in &variables {
let name = &variable.name;
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.enumerate()
.all(|(i, c)| c.is_ascii_alphabetic() || c == b'_' || i > 0 && c.is_ascii_digit())
|| matches!(
name.as_str(),
"NAME"
| "FILE_NAME"
| "PACKAGE"
| "PACKAGE_NAME"
| "PROJECT_NAME"
| "USER"
| "DATE"
| "TIME"
| "YEAR"
| "END"
)
|| !names.insert(name)
{
return Err(format!("{what}: invalid, reserved or duplicate variable name {name:?}"));
}
title("file template variable prompt", &variable.prompt)?;
}
let ext = d.extension.trim_start_matches('.');
if (ext.is_empty() && d.file_name.is_none())
if ext.is_empty()
|| ext.len() > 32
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
@@ -847,7 +559,7 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
if body.len() > MAX_TEMPLATE {
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
}
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body, "fileName": d.file_name, "variables": variables });
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body });
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
out.files = vec![d.path.clone()];
Ok(out)
@@ -876,57 +588,6 @@ pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, S
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
const MAX_CATALOG_ENTRIES: usize = 20_000;
pub fn locale_asset(tag: &str) -> String {
format!("locale/{tag}")
}
fn locale_tag_ok(tag: &str) -> bool {
let mut parts = tag.split('-');
let language = parts.next().unwrap_or_default();
(2..=3).contains(&language.len())
&& language.chars().all(|c| c.is_ascii_alphabetic())
&& parts.all(|p| (1..=8).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphanumeric()))
}
/// `locales/<tag>.json`: English source text → translation, for the plugin's own strings.
pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, String> {
let dir = Path::new(LOCALES_DIR);
let mut names: Vec<String> = std::fs::read_dir(pkg.dir.join(dir))
.map_err(|e| format!("{LOCALES_DIR}: {e}"))?
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
.filter(|name| name.ends_with(".json"))
.collect();
names.sort();
let mut tags = Vec::new();
let mut files = Vec::new();
let mut assets = Vec::new();
for name in names {
let tag = name.trim_end_matches(".json");
if !locale_tag_ok(tag) {
return Err(format!("{LOCALES_DIR}/{name}: the file name must be a language tag such as fr or zh-CN"));
}
let file = dir.join(&name);
let text = pkg.read_text(&file)?;
let catalog: BTreeMap<String, Value> = serde_json::from_str(&text)
.map_err(|e| format!("{}: not a JSON object of strings ({e})", file.display()))?;
if catalog.len() > MAX_CATALOG_ENTRIES {
return Err(format!("{}: more than {MAX_CATALOG_ENTRIES} entries", file.display()));
}
if let Some((key, _)) = catalog.iter().find(|(_, v)| !v.is_string()) {
return Err(format!("{}: the value of {key:?} is not a string", file.display()));
}
assets.push((locale_asset(tag), text));
tags.push(tag.to_owned());
files.push(file);
}
let mut out = UiDecl::new("locales", tags.join(", "), serde_json::json!({ "tags": tags }));
out.files = files;
out.assets = assets;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -958,139 +619,4 @@ mod tests {
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err());
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
}
#[test]
fn collections_validate_every_cell_and_keep_schema_order() {
let schema =
parse_settings_schema(include_str!("../../../ide-plugin/tests/fixtures/settings-pages/llm.json")).unwrap();
let profiles = &schema[0];
let item = profiles.items.as_ref().unwrap();
assert_eq!(
item.properties.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(),
["name", "provider", "baseUrl", "model", "enabled", "temperature", "limit"]
);
assert!(profiles.accepts(&serde_json::json!([item.default])));
for (field, value) in [
("provider", Value::from("unknown")),
("enabled", Value::from("true")),
("limit", Value::from(1.5)),
("model", Value::Null),
] {
let mut row = item.default.clone();
row[field] = value;
assert!(!profiles.accepts(&serde_json::json!([row])), "{field}");
}
assert!(!profiles.accepts(&serde_json::json!([{}])));
for items in [
r#"{"type":"number"}"#,
r#"{"type":"array","items":{"type":"string"}}"#,
r#"{"type":"object","properties":{}}"#,
r#"{"type":"object","properties":{"nested":{"type":"object"}}}"#,
] {
assert!(
parse_settings_schema(&format!(r#"{{"properties":{{"x":{{"type":"array","items":{items}}}}}}}"#))
.is_err()
);
}
let lists = parse_settings_schema(
r#"{"properties":{"z":{"type":"array","items":{"type":"string"}},"a":{"type":"boolean"}}}"#,
)
.unwrap();
assert_eq!(lists[0].key, "z");
assert!(lists[0].accepts(&serde_json::json!(["feat", "fix"])));
assert!(!lists[0].accepts(&serde_json::json!([1])));
}
#[test]
fn schema_order_is_local_and_duplicate_fields_are_rejected() {
let raw = r#"{"properties":{"z":{"type":"string"},"a":{"type":"boolean"}}}"#;
let fields = parse_settings_schema(raw).unwrap();
assert_eq!(fields.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["z", "a"]);
let ordinary: Value = serde_json::from_str(raw).unwrap();
assert_eq!(ordinary["properties"].as_object().unwrap().keys().collect::<Vec<_>>(), ["a", "z"]);
for raw in [
r#"{"properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}"#,
r#"{"properties":{"rows":{"type":"array","items":{"type":"object","properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}}}}"#,
] {
assert!(parse_settings_schema(raw).unwrap_err().contains("duplicate setting key"));
}
}
#[test]
fn settings_pages_validate_manifest_files_commands_and_api() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/settings-pages");
let source = std::fs::read_to_string(root.join("plugin.toml")).unwrap();
let check = |source: &str| {
let manifest = ide_extension::Manifest::parse(source)?;
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
settings(&Package::new(manifest, root.clone()), decl)
};
let valid = check(&source).unwrap();
assert_eq!(valid.json["pages"].as_array().unwrap().len(), 2);
assert_eq!(valid.json["properties"].as_array().unwrap().len(), 3);
assert_eq!(valid.files.len(), 2);
for bad in [
source.replace("^1.3", "^1.2"),
source.replace(r#"id = "llm""#, r#"id = "templates""#),
source.replace(r#"command = "test""#, r#"command = "missing""#),
source.replace("llm.json", "missing.json"),
source.replace("llm.json", "../outside.json"),
source.replace("llm.json", "templates.json"),
source.replace(r#"id = "llm""#, r#"id = "llm.child""#),
] {
assert!(check(&bad).is_err(), "{bad}");
}
let old = source.split("[contributes.settings]").next().unwrap().replace("^1.3", "^1.0");
let old = format!("{old}\n[contributes.settings]\nschema = \"legacy.json\"\n");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("legacy.json"), r#"{"properties":{"enabled":{"type":"boolean"}}}"#).unwrap();
let manifest = ide_extension::Manifest::parse(&old).unwrap();
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
assert!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok());
for field in [r#"{"type":"string","group":"Provider"}"#, r#"{"type":"array","items":{"type":"string"}}"#] {
std::fs::write(dir.path().join("legacy.json"), format!(r#"{{"properties":{{"x":{field}}}}}"#)).unwrap();
for (api, valid) in [("^1.0", false), ("^1.2", false), ("^1.3", true)] {
let manifest = ide_extension::Manifest::parse(&old.replace("^1.0", api)).unwrap();
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
assert_eq!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok(), valid);
}
}
}
fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap();
for (name, text) in files {
std::fs::write(dir.path().join(LOCALES_DIR).join(name), text).unwrap();
}
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"";
let pkg = Package::new(ide_extension::Manifest::parse(src).unwrap(), dir.path().to_path_buf());
(dir, pkg)
}
#[test]
fn catalogs_become_assets_by_language_tag() {
let (_dir, pkg) = package_with_catalogs(&[
("fr.json", r#"{"Refresh": "Actualiser"}"#),
("zh-CN.json", r#"{"Refresh": "刷新"}"#),
("notes.txt", "ignored"),
]);
let decl = locales(&pkg, toml::Value::Table(Default::default())).unwrap();
assert_eq!(decl.json["tags"], serde_json::json!(["fr", "zh-CN"]));
assert_eq!(decl.assets.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["locale/fr", "locale/zh-CN"]);
}
#[test]
fn invalid_catalogs_are_refused() {
for (name, text) in [
("fr.json", "{"),
("fr.json", r#"["Refresh"]"#),
("fr.json", r#"{"Refresh": 3}"#),
("fr.json", r#"{"Refresh": {"a": "b"}}"#),
("french!.json", "{}"),
] {
let (_dir, pkg) = package_with_catalogs(&[(name, text)]);
assert!(locales(&pkg, toml::Value::Table(Default::default())).is_err(), "{name}: {text}");
}
}
}
-214
View File
@@ -100,23 +100,6 @@ pub struct WebPage {
pub path: String,
}
/// A web frame's API 1.3 PTY request. The frame never supplies an executable path or cwd.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebPtyCommand {
pub program: String,
pub args: Vec<String>,
pub env: std::collections::BTreeMap<String, String>,
pub cols: u16,
pub rows: u16,
}
pub(super) struct WebPtySession {
id: ide_core::TerminalId,
host: std::sync::Weak<crate::Host>,
window: String,
}
/// A file of a web view.
pub struct WebFile {
pub bytes: Vec<u8>,
@@ -124,203 +107,6 @@ pub struct WebFile {
}
impl PluginHost {
/// Uses the normal PTY backend, without user shell fallback or startup commands.
#[allow(clippy::too_many_arguments)]
pub fn spawn_web_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.web_pty_options(plugin_id, view, window, command)
})
}
/// Starts the program of the log tab `key` that the plugin's code opened
/// in `window` (`views.open-log-tab`), with the toggles in `on`: the UI
/// names the tab, never what runs.
#[allow(clippy::too_many_arguments)]
pub fn spawn_log_tab_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
key: &str,
window: &str,
on: &[String],
(cols, rows): (u16, u16),
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.log_tab_pty_options(plugin_id, key, window, on, (cols, rows))
})
}
/// The PTY of a plugin, counted against its sessions and ended with its
/// window, project or permission; `options` authorizes it.
fn spawn_plugin_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
window: &str,
output: crate::ops::Emit<String>,
options: impl FnOnce() -> IdeResult<ide_terminal::TerminalOptions>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let _guard = self.lifecycle.lock();
let mut sessions = self.web_ptys.lock();
if self.shared.client_closed(window) {
return Err(IdeError::cancelled());
}
let options = options()?;
let entries = sessions.entry(plugin_id.to_owned()).or_default();
entries.retain(|s| s.host.upgrade().is_some_and(|h| h.terminals.contains(s.id)));
if entries.len() >= 8 {
return Err(IdeError::invalid("a plugin may own at most 8 PTYs"));
}
let cwd = options.cwd.0.clone();
let info = host.terminals.spawn_program(
options,
host.owner_window(window).to_owned(),
host.window_sink(window),
Box::new(move |data| output(data)),
)?;
if !self
.shared
.workspace()
.is_some_and(|workspace| workspace.project_of(window).as_ref() == Some(&cwd) && workspace.trusted(&cwd))
{
let _ = host.terminals.kill(info.id);
return Err(IdeError::cancelled());
}
entries.push(WebPtySession { id: info.id, host: std::sync::Arc::downgrade(host), window: window.to_owned() });
Ok(info)
}
pub(super) fn forget_web_ptys(&self, window: &str) {
self.shared.forget_log_tabs(None, Some(window));
for sessions in self.web_ptys.lock().values_mut() {
sessions.retain(|session| {
if session.window != window {
return true;
}
if let Some(host) = session.host.upgrade() {
let _ = host.terminals.kill(session.id);
}
false
});
}
}
pub(super) fn stop_web_ptys(&self, plugin: &str) {
self.shared.forget_log_tabs(Some(plugin), None);
if let Some(sessions) = self.web_ptys.lock().remove(plugin) {
for session in sessions {
if let Some(host) = session.host.upgrade() {
let _ = host.terminals.kill(session.id);
}
}
}
}
/// Authorizes a PTY immediately before spawning through the existing terminal backend.
pub fn web_pty_options(
&self,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
self.web_page(plugin_id, view)?;
self.get(plugin_id)?.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
self.pty_options(plugin_id, window, command)
}
/// The options of the log tab `key`'s program, with the toggles in `on`.
pub fn log_tab_pty_options(
&self,
plugin_id: &str,
key: &str,
window: &str,
on: &[String],
(cols, rows): (u16, u16),
) -> IdeResult<ide_terminal::TerminalOptions> {
let log = self.shared.log_tab(plugin_id, window, key).ok_or_else(|| {
IdeError::not_found(format!("plugin {plugin_id:?} opened no log tab {key:?} in this window"))
})?;
let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args);
let args = log.args.into_iter().chain(toggled).collect();
let command = WebPtyCommand { program: log.program, args, env: Default::default(), cols, rows };
self.pty_options(plugin_id, window, command)
}
/// Checks `command` against the plugin's current grant, the limits and
/// the window's trusted project.
fn pty_options(
&self,
plugin_id: &str,
window: &str,
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
let plugin = self.get(plugin_id)?;
let cap = ide_extension::Capability::Process(command.program.clone());
if !plugin.meta.lock().granted.contains(&cap) {
return Err(IdeError::new(
ide_core::error::ErrorKind::PermissionDenied,
format!("{cap} capability not granted"),
));
}
if command.program.is_empty()
|| command.program.len() > 256
|| command.program.trim() != command.program
|| command.program.contains(['\0', '\r', '\n'])
|| command.args.len() > 256
|| command.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|| !(2..=500).contains(&command.cols)
|| !(1..=500).contains(&command.rows)
{
return Err(IdeError::invalid("invalid PTY program, arguments or dimensions"));
}
if command.env.len() > 64
|| command.env.iter().any(|(k, v)| {
k.is_empty()
|| k.len() > 256
|| k.contains(['=', '\0'])
|| v.len() > 4096
|| v.contains('\0')
|| std::env::vars_os().any(|(inherited, _)| {
if cfg!(windows) {
inherited.to_string_lossy().eq_ignore_ascii_case(k)
} else {
inherited == k.as_str()
}
})
})
{
return Err(IdeError::invalid("PTY environment may only add variables, within the size limits"));
}
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
let cwd = workspace.project_of(window).ok_or_else(|| IdeError::invalid("no project in this window"))?;
if !cwd.is_absolute() || !cwd.is_dir() {
return Err(IdeError::invalid("the project directory no longer exists"));
}
if !workspace.trusted(&cwd) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
}
let program = self.program_for(plugin_id, &command.program)?;
Ok(ide_terminal::TerminalOptions {
cwd: cwd.into(),
shell: Some(program.to_string_lossy().into_owned()),
args: command.args,
env: command.env,
cols: command.cols,
rows: command.rows,
shell_integration: false,
})
}
/// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated).
fn web_dirs(&self, plugin: &str) -> Vec<String> {
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
+2 -28
View File
@@ -20,7 +20,6 @@ use std::sync::atomic::{AtomicBool, Ordering};
/// Everything bound to one open project folder.
pub struct Project {
pub(crate) trusted: Arc<AtomicBool>,
pub(crate) files_events_active: Arc<AtomicBool>,
pub root: PathBuf,
/// Window-scoped event sink, for commands that emit project events.
pub sink: SharedSink,
@@ -28,7 +27,6 @@ pub struct Project {
pub languages: Arc<LanguageRegistry>,
pub settings: Arc<SettingsStore>,
pub documents: Arc<Documents>,
pub(crate) plugin_diagnostics: Arc<crate::plugins::diagnostics::Diagnostics>,
pub history: Arc<LocalHistory>,
/// Undo stack of file move / rename refactorings.
pub moves: FileMoves,
@@ -195,7 +193,6 @@ impl Host {
&effective.project_structure,
),
);
ide_lsp::jdtls::configure_project_structure(cfg, root, server_root, &effective.project_structure);
let bundles: Vec<PathBuf> = ide_tools::managed_bundle(&data_dir, "java-debug")
.into_iter()
.chain(ide_sdk::java::usages_bundle(&data_dir))
@@ -216,19 +213,13 @@ impl Host {
cfg.launch_error = Some(e);
None
});
let lang_level = ide_sdk::apply::effective_java_language_level(root, server_root, &effective);
ide_sdk::apply::configure_java_compiler_with_level(
cfg,
&data_dir,
&effective.inspections,
lang_level,
);
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
ide_sdk::java::configure_jdtls(
cfg,
server_root,
&data_dir,
&jdks,
ide_sdk::apply::module_jdk(root, server_root, &effective).as_ref(),
ide_sdk::apply::project_jdk(root, &effective).as_ref(),
server_jdk.as_ref(),
min_java,
&bundles,
@@ -320,10 +311,7 @@ impl Host {
// One-shot latch for the post-start watch-limit warning (#1915): the
// synchronous startup walk and a later batch can both observe it.
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
let files_events_active = Arc::new(AtomicBool::new(true));
let watcher = {
let files_events_active = files_events_active.clone();
let window = window.to_owned();
let kotlin_bridge = kotlin_bridge.clone();
let phpstan = phpstan.clone();
let repos = repos.clone();
@@ -359,10 +347,6 @@ impl Host {
}
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&[&changed[..], &ignored].concat());
if let Some(plugins) = crate::plugins::editor_plugin_host() {
plugins.invalidate_endpoints(&root, &[changed.clone(), batch.removed.clone()].concat());
plugins.post_files_changed(&window, &root, &files_events_active, batch);
}
// A framework's root marker (`angular.json`) created or deleted: its
// templates gain or lose their mode. Markers in `node_modules` only
// count here when the folder isn't excluded (it is by default).
@@ -525,11 +509,7 @@ impl Host {
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root);
documents.add_observer(plugin_diagnostics.clone());
let project = Arc::new(Project {
plugin_diagnostics,
trusted,
root,
sink,
@@ -559,7 +539,6 @@ impl Host {
content_watchers: Mutex::new(content_watchers),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: scratch_watcher,
files_events_active,
light: false,
});
{
@@ -676,11 +655,7 @@ impl Host {
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &dir);
documents.add_observer(plugin_diagnostics.clone());
let project = Arc::new(Project {
plugin_diagnostics,
trusted: Arc::new(AtomicBool::new(false)),
sink,
languages,
@@ -709,7 +684,6 @@ impl Host {
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
files_events_active: Arc::new(AtomicBool::new(false)),
light: true,
root: dir,
});
-191
View File
@@ -1,191 +0,0 @@
//! `openidea-server --connect <host> <port>`: one forwarded connection. The desktop starts it
//! in a dev container (`docker exec -i`) for each connection to a forwarded port and pipes the
//! connection through stdin / stdout, so a port of the container is reachable wherever the
//! container's network is not.
use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpStream};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const EOF_GRACE: Duration = Duration::from_secs(5);
/// `<host> <port>` from the arguments after `--connect`.
pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
let [host, port] = args else { return Err("usage: openidea-server --connect <host> <port>".into()) };
if host.is_empty() || host.starts_with('-') || host.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err(format!("invalid host {host:?}"));
}
match port.parse::<u16>() {
Ok(p) if p != 0 => Ok((host.clone(), p)),
_ => Err(format!("invalid port {port:?}")),
}
}
/// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when
/// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only
/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply.
pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> {
let mut from_target = connect(host, port)?;
from_target.set_read_timeout(Some(Duration::from_millis(100)))?;
let mut to_target = from_target.try_clone()?;
let writing = Arc::new(Mutex::new(None::<Instant>));
let stalled = writing.clone();
let (finished, eof) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = [0; 16 * 1024];
loop {
let n = match input.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
};
*writing.lock().unwrap() = Some(Instant::now());
let result = to_target.write_all(&buf[..n]);
*writing.lock().unwrap() = None;
if result.is_err() {
break;
}
}
// The client closed its side: tell the target, which may still answer.
let _ = to_target.shutdown(Shutdown::Write);
let _ = finished.send(Instant::now());
});
let mut buf = [0u8; 16 * 1024];
let mut input_closed = None;
loop {
// A blocked write must not hide Docker closing stdin indefinitely.
if stalled.lock().unwrap().is_some_and(|at| at.elapsed() >= CONNECT_TIMEOUT) {
break;
}
if let Ok(at) = eof.try_recv() {
input_closed = Some(at);
}
// Docker closing stdin must end an idle relay too. Keep a bounded grace for
// a target replying to a half-closed request before tearing down the socket.
if input_closed.is_some_and(|at| at.elapsed() >= EOF_GRACE) {
break;
}
match from_target.read(&mut buf) {
Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => continue,
Ok(0) | Err(_) => break,
Ok(n) => {
if output.write_all(&buf[..n]).and_then(|()| output.flush()).is_err() {
break;
}
if input_closed.is_some() {
input_closed = Some(Instant::now());
}
}
}
}
let _ = from_target.shutdown(Shutdown::Both);
Ok(())
}
fn connect(host: &str, port: u16) -> io::Result<TcpStream> {
use std::net::ToSocketAddrs;
let mut last = io::Error::new(io::ErrorKind::NotFound, format!("{host} has no address"));
for addr in (host, port).to_socket_addrs()? {
match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
Ok(s) => return Ok(s),
Err(e) => last = e,
}
}
Err(last)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn reads_host_and_port() {
assert_eq!(parse_args(&args(&["localhost", "8000"])).unwrap(), ("localhost".into(), 8000));
for bad in [&["localhost"][..], &["localhost", "0"], &["localhost", "70000"], &["-x", "80"], &["a b", "80"]] {
assert!(parse_args(&args(bad)).is_err(), "{bad:?}");
}
}
#[test]
fn pipes_a_request_and_its_reply() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut request = String::new();
s.read_to_string(&mut request).unwrap();
s.write_all(format!("got {request}").as_bytes()).unwrap();
});
let mut out = Vec::new();
relay("127.0.0.1", port, &b"ping"[..], &mut out).unwrap();
server.join().unwrap();
assert_eq!(out, b"got ping");
}
#[test]
fn input_eof_ends_a_relay_even_when_the_target_ignores_it() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (done, wait) = mpsc::channel();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut request = Vec::new();
socket.read_to_end(&mut request).unwrap();
// Remain open after seeing EOF, like an idle keep-alive server.
wait.recv_timeout(Duration::from_secs(10)).unwrap();
});
let start = Instant::now();
relay("127.0.0.1", port, io::empty(), io::sink()).unwrap();
assert!(start.elapsed() < Duration::from_secs(8));
done.send(()).unwrap();
server.join().unwrap();
}
#[test]
fn final_responses_keep_flowing_after_input_eof() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
socket.read_to_end(&mut Vec::new()).unwrap();
for _ in 0..7 {
socket.write_all(b"x").unwrap();
std::thread::sleep(Duration::from_secs(1));
}
});
let mut out = Vec::new();
relay("127.0.0.1", port, io::empty(), &mut out).unwrap();
assert_eq!(out, b"xxxxxxx");
server.join().unwrap();
}
#[test]
fn a_target_that_never_reads_cannot_keep_a_relay_alive() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (done, wait) = mpsc::channel();
let server = std::thread::spawn(move || {
let (_socket, _) = listener.accept().unwrap();
wait.recv_timeout(Duration::from_secs(30)).unwrap();
});
let start = Instant::now();
relay("127.0.0.1", port, io::repeat(0).take(32 * 1024 * 1024), io::sink()).unwrap();
assert!(start.elapsed() < Duration::from_secs(25));
done.send(()).unwrap();
server.join().unwrap();
}
#[test]
fn a_closed_port_is_an_error() {
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
assert!(relay("127.0.0.1", port, io::empty(), io::sink()).is_err());
}
}
+2 -21
View File
@@ -16,14 +16,8 @@ pub struct TrustDecisions {
pub struct TrustStore {
file: PathBuf,
state: Mutex<IdeResult<TrustDecisions>>,
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
inherited: Mutex<Vec<PathBuf>>,
}
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
/// there: the project folder in the container, trusted without asking again. Never persisted.
pub const TRUSTED_ROOT_ENV: &str = "OPENIDEA_TRUSTED_ROOT";
impl TrustStore {
pub fn load(config: &Path, data: &Path) -> Self {
let file = data.join("project-trust.json");
@@ -40,7 +34,7 @@ impl TrustStore {
}
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
Self { file, state: Mutex::new(state) }
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
@@ -51,19 +45,7 @@ impl TrustStore {
let root = dunce::canonicalize(path)?;
let decisions = self.decisions()?;
Ok(!decisions.restricted.contains(&root)
&& (decisions.projects.contains(&root)
|| decisions.locations.iter().any(|p| root.starts_with(p))
|| self.inherited.lock().contains(&root)))
}
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
let root = dunce::canonicalize(path)?;
let mut inherited = self.inherited.lock();
if !inherited.contains(&root) {
inherited.push(root);
}
Ok(())
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
}
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
@@ -318,7 +300,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "plugin_set_theme_frame"
| "plugin_catalog"
| "plugin_assets"
| "plugin_set_ui_locale"
| "plugin_market"
| "plugin_market_url"
| "plugin_set_market_url"
+1 -7
View File
@@ -58,13 +58,7 @@ fn fixture() -> Fixture {
}
fn inv() -> Invocation {
Invocation {
client: Some("main".into()),
workspace_root: None,
editor: None,
selected_paths: Vec::new(),
global_settings: false,
}
Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() }
}
fn misbehave() -> PathBuf {
@@ -117,41 +117,6 @@ async fn docker_targets_run_through_the_docker_cli() {
assert!(out.contains("DATA=/workspaces/proj/data"), "{out}");
assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line");
// Debug: the program starts under the bootstrap, which waits for the debugger on a
// port published on the loopback interface, with the IDE's debugpy mounted.
let lib = host.data_dir.join("tools/debugpy/lib");
std::fs::create_dir_all(lib.join("debugpy")).unwrap();
std::fs::write(lib.join("debugpy/__init__.py"), "").unwrap();
let mut debug = config("py", &root);
debug.debug_adapter = Some("debugpy".into());
debug.extras.params.insert("containerDebug".into(), "true".into());
let out = output_of(&host, debug.clone()).await;
let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect();
let publish = args[args.iter().position(|a| *a == "--publish").unwrap() + 1];
let port = publish.strip_prefix("127.0.0.1:").and_then(|p| p.split_once(':')).unwrap();
assert_eq!(port.0, port.1, "{out}");
let bootstrap = host.data_dir.join("container-debug");
assert!(args.contains(&format!("{}:/opt/openidea/debug:ro", bootstrap.display()).as_str()), "{out}");
assert!(args.contains(&format!("{}:/opt/openidea/debugpy-lib:ro", lib.display()).as_str()), "{out}");
assert_eq!(
std::fs::read_to_string(bootstrap.join("openidea_debug.py")).unwrap(),
ide_remote::docker::DEBUG_BOOTSTRAP
);
assert!(
args.ends_with(&[
"python:3.12",
"python3",
"/opt/openidea/debug/openidea_debug.py",
port.0,
"/workspaces/proj",
"/workspaces/proj/main.py"
]),
"{out}"
);
debug.debug_adapter = Some("delve".into());
let err = ops::run::start(&host, "main", debug, Arc::new(|_| {})).await.unwrap_err();
assert!(err.message.contains("only Python"), "{}", err.message);
let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err();
assert!(err.message.contains("another machine"), "{}", err.message);
}
@@ -157,150 +157,3 @@ async fn runs_in_docker_and_compose_containers() {
assert_eq!(containers("label=openidea.run=sleep"), "", "{out}");
Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap();
}
/// A debug adapter connection: just enough DAP to drive debugpy.
struct Dap {
reader: std::io::BufReader<std::net::TcpStream>,
writer: std::net::TcpStream,
seq: i64,
}
impl Dap {
fn connect(address: &str) -> Self {
let stream = std::net::TcpStream::connect(address).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(60))).unwrap();
Self { reader: std::io::BufReader::new(stream.try_clone().unwrap()), writer: stream, seq: 0 }
}
fn send(&mut self, command: &str, arguments: serde_json::Value) {
use std::io::Write;
self.seq += 1;
let body = serde_json::json!({"seq": self.seq, "type": "request", "command": command, "arguments": arguments})
.to_string();
write!(self.writer, "Content-Length: {}\r\n\r\n{body}", body.len()).unwrap();
}
fn read(&mut self) -> serde_json::Value {
use std::io::{BufRead, Read};
let mut length = 0;
loop {
let mut line = String::new();
assert!(self.reader.read_line(&mut line).unwrap() > 0, "the adapter closed the connection");
let line = line.trim();
if line.is_empty() {
break;
}
if let Some(n) = line.strip_prefix("Content-Length:") {
length = n.trim().parse().unwrap();
}
}
let mut body = vec![0; length];
self.reader.read_exact(&mut body).unwrap();
serde_json::from_slice(&body).unwrap()
}
/// Reads until a message of `kind` (`"response"` / `"event"`) named `name`.
fn wait(&mut self, kind: &str, name: &str) -> serde_json::Value {
loop {
let m = self.read();
let key = if kind == "event" { "event" } else { "command" };
if m["type"] == kind && m[key] == name {
return m;
}
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires a Docker daemon, OPENIDEA_DOCKER_TEST_DIR, and pip to fetch debugpy"]
async fn debugs_python_in_a_container() {
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
// A project of its own: the other test may run at the same time.
let root = dunce::canonicalize(root).unwrap().join("debug");
std::fs::create_dir_all(&root).unwrap();
let name = "debug";
let main = root.join("main.py");
std::fs::write(&main, "import os\nvalue = os.environ['GREETING']\nprint('value=' + value)\n").unwrap();
// The IDE's state must be where the daemon can mount it (the bootstrap, the debugpy copy).
let state = root.join(".state");
let host = host(&state);
// The IDE's copy, installed like the Tools page does (`ide_tools` `PipTarget`): the image has no debugpy.
let lib = host.data_dir.join("tools/debugpy/lib");
let pip = Command::new("python3")
.args(["-m", "pip", "install", "--quiet", "--no-deps", "--target"])
.arg(&lib)
.args(["--only-binary=:all:", "--platform", "any", "--implementation", "py", "debugpy"])
.status()
.unwrap();
assert!(pip.success());
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let targets =
serde_json::json!([{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" }]);
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"runTargets",
&targets.to_string(),
)
.unwrap();
let mut debug = config("debug-main", "py", &root, &[&main.to_string_lossy()]);
debug.debug_adapter = Some("debugpy".into());
debug.extras.params.insert("containerDebug".into(), "true".into());
let (_, out) = start(&host, debug).await;
let mut ready = None;
for _ in 0..1200 {
ready = out
.lock()
.lines()
.find_map(|l| l.trim().strip_prefix(ide_remote::docker::DEBUG_READY).map(|rest| rest.to_owned()));
if ready.is_some() || out.lock().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let ready = ready.unwrap_or_else(|| panic!("{}", out.lock()));
let (port, folder) = ready.split_once(", project folder ").unwrap();
assert_eq!(folder, format!("/workspaces/{name}"));
let address = format!("{}:{port}", std::env::var("OPENIDEA_DOCKER_TEST_HOST").unwrap_or("127.0.0.1".into()));
// Attach with the project mapped, stop at a breakpoint set on the local file, continue.
let local = main.to_string_lossy().into_owned();
let root_str = root.to_string_lossy().into_owned();
let folder = folder.to_owned();
let stopped_at = tokio::task::spawn_blocking(move || {
let mut dap = Dap::connect(&address);
dap.send(
"initialize",
serde_json::json!({"adapterID": "debugpy", "pathFormat": "path", "linesStartAt1": true}),
);
dap.wait("response", "initialize");
dap.send(
"attach",
serde_json::json!({"justMyCode": true, "pathMappings": [{"localRoot": root_str, "remoteRoot": folder}]}),
);
dap.wait("event", "initialized");
dap.send("setBreakpoints", serde_json::json!({"source": {"path": local}, "breakpoints": [{"line": 3}]}));
let set = dap.wait("response", "setBreakpoints");
assert_eq!(set["body"]["breakpoints"][0]["verified"], true, "{set}");
dap.send("configurationDone", serde_json::json!({}));
let stopped = dap.wait("event", "stopped");
let thread = stopped["body"]["threadId"].clone();
dap.send("stackTrace", serde_json::json!({"threadId": thread}));
let frame = dap.wait("response", "stackTrace")["body"]["stackFrames"][0].clone();
dap.send("continue", serde_json::json!({"threadId": thread}));
dap.wait("response", "continue");
(stopped["body"]["reason"].clone(), frame["source"]["path"].clone(), frame["line"].clone())
})
.await
.unwrap();
assert_eq!(stopped_at.0, "breakpoint");
assert_eq!(stopped_at.1, main.to_string_lossy().as_ref(), "the container's path maps back to the project");
assert_eq!(stopped_at.2, 3);
let out = finished(&out, 60).await;
assert!(out.contains("value=hello") && out.contains("exit code 0"), "{out}");
assert_eq!(containers("label=openidea.run=debug-main"), "", "removed when the run ends");
std::fs::remove_dir_all(&root).unwrap();
}
-432
View File
@@ -1,432 +0,0 @@
//! Real Wasm provider dispatch and the document-edit path used locally and over RPC.
use ide_core::{CancellationToken, ChangeSet, Diagnostic, IdeEvent, Pos, Range, TextChange};
use ide_host::plugins::{Consent, HostWorkspace, PluginEvent, PluginHost, set_editor_providers};
use ide_host::{Host, HostConfig, ops};
use ide_plugin::{Invocation, Limits, ProviderDocument};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::{Arc, mpsc};
use std::time::{Duration, Instant};
const ID: &str = "test.diagnostics";
const SOURCE: &str = "plugin-provider:test.diagnostics";
fn fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics")
}
fn plugins(dir: &Path, limits: Limits) -> Arc<PluginHost> {
let host = Arc::new(PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap());
host.install_from_dir(&fixture(), &Consent::all()).unwrap();
host
}
fn generous() -> Limits {
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() }
}
fn doc(root: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: root.join("a.txt"), language: language.into(), version: 42, text: text.into() }
}
#[test]
fn declared_languages_receive_versioned_text_and_utf16_ranges() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), generous());
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "😀BAD\nBAD"), &CancellationToken::new());
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, ID);
let items = &result[0].1;
assert_eq!(items.len(), 2);
assert_eq!(items[0].range, Range::new(Pos::new(0, 2), Pos::new(0, 5)));
assert_eq!(items[1].range, Range::new(Pos::new(1, 0), Pos::new(1, 3)));
assert_eq!(items[0].message, "BAD in text at version 42");
assert_eq!(items[0].code.as_deref(), Some("bad-word"));
assert_eq!(items[0].server.as_deref(), Some(ID));
assert!(host.provide_diagnostics(&doc(tmp.path(), "rust", "BAD"), &CancellationToken::new()).is_empty());
}
#[test]
fn provider_diagnostics_have_the_same_bounds_and_tags_as_manual_publish() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), generous());
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "limits"), &CancellationToken::new());
let items = &result[0].1;
assert_eq!(items.len(), 5000);
assert_eq!(items[0].message.len(), 4096);
assert_eq!(items[0].source.as_ref().unwrap().len(), 128);
assert_eq!(items[0].code.as_ref().unwrap().len(), 128);
assert!(items[0].unnecessary && items[0].deprecated);
}
#[test]
fn the_export_does_not_need_project_read_permission() {
let tmp = tempfile::tempdir().unwrap();
let package = tmp.path().join("no-capability");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(package.join("plugin.toml"), manifest.replace("[capabilities]\nrequired = [\"project.read\"]", ""))
.unwrap();
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
host.install_from_dir(&package, &Consent::default()).unwrap();
assert_eq!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).len(), 1);
assert!(host.run_command(ID, "publish", Some("/project/a.txt"), Invocation::default()).is_err());
}
#[test]
fn diagnostics_is_an_api_1_3_addition() {
let tmp = tempfile::tempdir().unwrap();
let package = tmp.path().join("old");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(package.join("plugin.toml"), manifest.replace("^1.3", "^1.0")).unwrap();
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
assert!(host.install_from_dir(&package, &Consent::all()).unwrap_err().message.contains("new in plugin API 1.3"));
}
// The fixture signals from inside a host import, so the actor cannot finish
// until this test releases it. A dropped guard also releases a failing test.
enum FixtureEvent {
Entered,
Returned,
}
struct FixtureGate {
events: mpsc::Receiver<FixtureEvent>,
finished: mpsc::Sender<FixtureEvent>,
release: mpsc::Sender<()>,
}
impl FixtureGate {
fn new(dir: &Path, limits: Limits) -> (Arc<PluginHost>, Self) {
let (started, events) = mpsc::channel();
let finished = started.clone();
let (release, released) = mpsc::channel();
let released = Mutex::new(released);
let sink = Arc::new(move |_: Option<&str>, event| {
if matches!(event, PluginEvent::ContextChanged { ref plugin_id, ref key, value: Some(_), .. } if plugin_id == ID && key == "fixture-gate")
{
started.send(FixtureEvent::Entered).unwrap();
released.lock().recv_timeout(Duration::from_secs(5)).unwrap();
}
});
let host = Arc::new(PluginHost::new(dir.join("plugins"), sink, limits).unwrap());
host.install_from_dir(&fixture(), &Consent::all()).unwrap();
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
(host, Self { events, finished, release })
}
fn entered(&self) {
assert!(matches!(self.events.recv_timeout(Duration::from_secs(5)).unwrap(), FixtureEvent::Entered));
}
fn provider(
&self,
host: &Arc<PluginHost>,
document: ProviderDocument,
cancel: &CancellationToken,
) -> std::thread::JoinHandle<Vec<(String, Vec<Diagnostic>)>> {
let start = Instant::now();
loop {
let (host, document, cancel, finished) =
(host.clone(), document.clone(), cancel.clone(), self.finished.clone());
let call = std::thread::spawn(move || {
let result = host.provide_diagnostics(&document, &cancel);
let _ = finished.send(FixtureEvent::Returned);
result
});
if matches!(self.events.recv_timeout(Duration::from_secs(5)).unwrap(), FixtureEvent::Entered) {
return call;
}
// Expiry before entry is valid, but cannot exercise a late answer.
assert!(call.join().unwrap().is_empty());
assert!(start.elapsed() < Duration::from_secs(5), "provider never entered the fixture");
}
}
}
impl Drop for FixtureGate {
fn drop(&mut self) {
let _ = self.release.send(());
}
}
#[test]
fn late_and_cancelled_answers_are_dropped_without_disabling_the_plugin() {
let tmp = tempfile::tempdir().unwrap();
let soft = Duration::from_millis(10);
let (host, gate) = FixtureGate::new(tmp.path(), Limits { edit_soft: soft, ..generous() });
let call = gate.provider(&host, doc(tmp.path(), "text", "gate BAD"), &CancellationToken::new());
// Entry was observed: hold this actual provider beyond its soft deadline.
assert!(matches!(gate.events.recv_timeout(soft), Err(mpsc::RecvTimeoutError::Timeout)));
drop(gate);
assert!(call.join().unwrap().is_empty());
let cancel = CancellationToken::new();
cancel.cancel();
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1"));
}
#[test]
fn cancelling_a_started_provider_drops_its_answer_without_disabling_the_plugin() {
let tmp = tempfile::tempdir().unwrap();
let (host, gate) = FixtureGate::new(tmp.path(), generous());
let cancel = CancellationToken::new();
let call = gate.provider(&host, doc(tmp.path(), "text", "gate BAD"), &cancel);
cancel.cancel();
drop(gate);
assert!(call.join().unwrap().is_empty());
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1"));
}
#[test]
fn cancelling_a_queued_provider_does_not_interrupt_another_windows_command() {
let tmp = tempfile::tempdir().unwrap();
let (host, gate) = FixtureGate::new(tmp.path(), generous());
let waiter = {
let host = host.clone();
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
};
gate.entered();
let cancel = CancellationToken::new();
let canceller = {
let cancel = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(30));
cancel.cancel();
})
};
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
canceller.join().unwrap();
drop(gate);
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
}
#[test]
fn editor_requests_expire_in_the_queue_without_running_after_a_command() {
let tmp = tempfile::tempdir().unwrap();
let (host, gate) = FixtureGate::new(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() });
let waiter = {
let host = host.clone();
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
};
gate.entered();
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).is_empty());
drop(gate);
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
assert_eq!(host.info_of(ID).unwrap().stats.slow_calls, 1);
}
#[test]
fn typing_on_cancels_a_running_provider_before_its_hard_deadline() {
let tmp = tempfile::tempdir().unwrap();
let (host, gate) = FixtureGate::new(tmp.path(), Limits { fuel: u64::MAX, ..generous() });
let cancel = CancellationToken::new();
let call = gate.provider(&host, doc(tmp.path(), "text", "gate spin BAD"), &cancel);
let started = Instant::now();
cancel.cancel();
drop(gate);
assert!(call.join().unwrap().is_empty());
assert!(started.elapsed() < Duration::from_secs(2));
}
// This executable has one edit-path test because editor providers are installed once per process.
type Events = Arc<Mutex<Vec<(String, IdeEvent)>>>;
fn runtime_host(base: &Path, events: Events) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |window| {
let (events, window) = (events.clone(), window.to_owned());
Arc::new(move |event: IdeEvent| events.lock().push((window.clone(), event)))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn latest(events: &Events, window: &str, source: &str) -> Option<Vec<Diagnostic>> {
events.lock().iter().rev().find_map(|(w, e)| match e {
IdeEvent::Diagnostics { source: s, items, .. } if w == window && s == source => Some(items.clone()),
_ => None,
})
}
async fn wait_for(events: &Events, window: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
wait_for_source(events, window, SOURCE, predicate).await;
}
async fn wait_for_source(events: &Events, window: &str, source: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
let start = Instant::now();
loop {
if latest(events, window, source).is_some_and(|items| predicate(&items)) {
return;
}
assert!(start.elapsed() < Duration::from_secs(5), "missing diagnostics for {window}");
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn replace(host: &Host, window: &str, info: &ide_workspace::DocumentInfo, text: &str) -> u32 {
let project = host.project(window).unwrap();
let snapshot = project.documents.snapshot(info.id).unwrap();
let len = snapshot.text.encode_utf16().count() as u32;
ops::doc::edit(
host,
window,
info.id,
snapshot.version,
ChangeSet { changes: vec![TextChange { from: 0, to: len, insert: text.into() }] },
)
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window_lifetimes() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let path = root.join("a.txt");
std::fs::write(&path, "clean").unwrap();
let events: Events = Arc::default();
let host = runtime_host(&root.join("app"), events.clone());
let plugins = plugins(&root, generous());
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
set_editor_providers(Some(&plugins));
let left = host.open_light("left", &path).unwrap();
host.open_light("right", &path).unwrap();
let a = ops::doc::open(&host, "left", path.clone()).await.unwrap();
let b = ops::doc::open(&host, "right", path.clone()).await.unwrap();
let second_tab = ops::doc::open(&host, "right", path.clone()).await.unwrap();
assert_eq!(b.id, second_tab.id);
wait_for(&events, "left", |items| items.is_empty()).await;
wait_for(&events, "right", |items| items.is_empty()).await;
let calls: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
events.lock().clear();
replace(&host, "left", &a, "BAD");
tokio::time::sleep(Duration::from_millis(100)).await;
let version = replace(&host, "left", &a, "😀BAD");
tokio::time::sleep(Duration::from_millis(150)).await;
assert!(latest(&events, "left", SOURCE).is_none(), "300ms after the latest edit");
wait_for(&events, "left", |items| !items.is_empty()).await;
assert_eq!(
latest(&events, "left", SOURCE).unwrap()[0].message,
format!("BAD in {} at version {version}", a.language)
);
assert!(latest(&events, "right", SOURCE).is_none(), "other window has its own buffer");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "clean", "no save required");
let after: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
assert_eq!(after, calls + 1, "the edit burst was coalesced");
plugins.run_command(ID, "publish", path.to_str(), Invocation::default()).unwrap();
let manual_window = events.lock().iter().find_map(|(window, event)| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "plugin:test.diagnostics" && !items.is_empty()).then(|| window.clone())).unwrap();
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
replace(&host, "left", &a, "clean again");
wait_for(&events, "left", |items| items.is_empty()).await;
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
events.lock().clear();
replace(&host, "left", &a, "slow BAD");
tokio::time::sleep(Duration::from_millis(350)).await;
replace(&host, "left", &a, "latest clean");
wait_for(&events, "left", |items| items.is_empty()).await;
assert!(
!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())),
"superseded in-flight answers never publish"
);
replace(&host, "right", &b, "BAD right");
wait_for(&events, "right", |items| !items.is_empty()).await;
ops::doc::close(&host, "right", b.id).unwrap();
assert!(!latest(&events, "right", SOURCE).unwrap().is_empty(), "another pane still owns this document");
ops::doc::close(&host, "right", second_tab.id).unwrap();
wait_for(&events, "right", |items| items.is_empty()).await;
replace(&host, "left", &a, "BAD left");
wait_for(&events, "left", |items| !items.is_empty()).await;
plugins.set_enabled(ID, false).unwrap();
wait_for(&events, "left", |items| items.is_empty()).await;
plugins.set_enabled(ID, true).unwrap();
events.lock().clear();
replace(&host, "left", &a, "slow BAD closing");
tokio::time::sleep(Duration::from_millis(350)).await;
host.close_project("left");
// Keep the old Project alive deliberately: window reuse must still cancel it.
assert_eq!(left.documents.snapshot(a.id).unwrap().text, "slow BAD closing");
host.open_light("left", &path).unwrap();
let reopened = ops::doc::open(&host, "left", path.clone()).await.unwrap();
wait_for(&events, "left", |items| items.is_empty()).await;
assert!(!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())));
// Project plugins receive only snapshots from the project that owns their declaration.
let project_package = root.join(ide_host::plugins::DEV_PLUGINS_DIR).join("diagnostics");
std::fs::create_dir_all(project_package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), project_package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(project_package.join("plugin.toml"), manifest.replace(ID, "project.diagnostics")).unwrap();
plugins.set_project_policy(Arc::new(|_| true));
plugins.sync_project(&root);
plugins.grant("project.diagnostics", &Consent::all()).unwrap();
replace(&host, "left", &reopened, "BAD in project");
wait_for(&events, "left", |items| !items.is_empty()).await;
let project_source = "plugin-provider:project.diagnostics";
wait_for_source(&events, "left", project_source, |items| !items.is_empty()).await;
let elsewhere = root.join("other");
std::fs::create_dir_all(&elsewhere).unwrap();
let other_path = elsewhere.join("other.txt");
std::fs::write(&other_path, "BAD outside").unwrap();
host.open_light("elsewhere", &other_path).unwrap();
ops::doc::open(&host, "elsewhere", other_path).await.unwrap();
wait_for(&events, "elsewhere", |items| !items.is_empty()).await;
assert!(latest(&events, "elsewhere", project_source).is_none());
plugins.restrict_project(&root);
assert!(latest(&events, "left", project_source).unwrap().is_empty());
// A completed answer may still be waiting for a sibling when its plugin crashes.
let sibling = root.join("slow-sibling");
std::fs::create_dir_all(sibling.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), sibling.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(
sibling.join("plugin.toml"),
manifest.replace(ID, "test.slow").replace("[\"text\", \"plaintext\"]", "[\"plaintext\"]"),
)
.unwrap();
plugins.install_from_dir(&sibling, &Consent::all()).unwrap();
plugins.run_command("test.slow", "calls", None, Invocation::default()).unwrap();
let calls = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap();
events.lock().clear();
replace(&host, "left", &reopened, "BAD completed before crash");
tokio::time::sleep(Duration::from_millis(200)).await;
let waiter = {
let plugins = plugins.clone();
std::thread::spawn(move || plugins.run_command("test.slow", "wait", None, Invocation::default()))
};
let started = Instant::now();
while plugins.run_command(ID, "calls", None, Invocation::default()).unwrap() == calls {
assert!(started.elapsed() < Duration::from_secs(5));
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
"the sibling still holds the result: {:?}",
latest(&events, "left", SOURCE)
);
assert!(plugins.provide_diagnostics(&doc(&root, "text", "panic"), &CancellationToken::new()).is_empty());
waiter.join().unwrap().unwrap();
wait_for_source(&events, "left", "plugin-provider:test.slow", |items| !items.is_empty()).await;
assert!(
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
"a crash invalidates completed answers"
);
ops::doc::close(&host, "left", reopened.id).unwrap();
host.shutdown().await;
set_editor_providers(None);
}
-55
View File
@@ -140,21 +140,6 @@ fn permissions_and_paths_are_checked_before_indexing() {
);
}
#[test]
fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() {
let f = Fixture::new();
let path = f.index("source.idx", "def item\nref item\n", &[], Some(1), "app");
let pos = Pos::new(0, 4);
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_some());
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
f.host.set_project_policy(Arc::new(|_| true));
f.host.set_capability("test.indexer", "project.read", false).unwrap();
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
f.host.set_capability("test.indexer", "project.read", true).unwrap();
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none(), "regrant requires fresh snapshots");
}
#[test]
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
let f = Fixture::new();
@@ -191,7 +176,6 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
workspace_root: Some(f.root.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
},
)
.unwrap();
@@ -255,42 +239,3 @@ fn invalid_ranges_and_oversized_documents_never_publish() {
}
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
}
#[test]
fn usages_from_definitions_and_references_follow_aliases_module_scope_and_client() {
let f = Fixture::new();
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
f.index("a/child.idx", "alias child# parent#\n", &[], None, "a");
let reference = f.index("a/use.idx", "ref child#item\n", &[], Some(1), "a");
let other = f.index("b/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "b");
let external = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
let unrelated = f.index("b/use.idx", "ref child#item\n", &[], Some(1), "b");
for (path, pos) in [(&definition, Pos::new(0, 4)), (&reference, Pos::new(0, 6))] {
let symbol = f.host.indexed_symbol_at(&f.root, path, pos, None).unwrap();
assert!(symbol.files.iter().any(|f| f.path == definition));
assert!(symbol.files.iter().any(|f| f.path == reference));
assert!(!symbol.files.iter().any(|f| f.path == other), "prefer same-module definitions and their uses");
assert!(symbol.files.iter().any(|f| f.path == external), "external reference resolves to this definition");
assert!(!symbol.files.iter().any(|f| f.path == unrelated), "reference resolves to its own module");
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
}
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 0), None).is_none());
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), Some("other-window")).is_none());
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), None).is_none());
}
#[test]
fn cross_module_reference_and_definition_have_the_same_rename_anchors() {
let f = Fixture::new();
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
f.index("a/alias.idx", "alias child# parent#\n", &[], None, "a");
let external = f.index("b/use.idx", "ref item\n", &[], Some(1), "b");
let alias = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
for path in [&definition, &external, &alias] {
let symbol = f.host.indexed_symbol_at(&f.root, path, Pos::new(0, 4), None).unwrap();
let paths: Vec<_> = symbol.files.iter().map(|f| f.path.clone()).collect();
assert_eq!(paths, vec![definition.clone(), external.clone(), alias.clone()]);
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
}
}
@@ -1,59 +0,0 @@
use ide_core::{Pos, Range};
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/inlay-hints")
}
fn host(dir: &Path) -> PluginHost {
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
}
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
}
const ORDER: &str = "<?php\nclass Order\n{\n public function user(): ?User {}\n}\n$order->user();\n$order->total();\n$order->user;\n";
fn all() -> Range {
Range::new(Pos::new(0, 0), Pos::new(100, 0))
}
#[test]
fn calls_of_declared_methods_get_a_type_hint() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
let hints = host.provide_inlay_hints(&doc(tmp.path(), "php", ORDER), all());
let found: Vec<_> =
hints.iter().map(|h| (h.position.line, h.position.col, h.label.as_str(), h.kind.as_str())).collect();
assert_eq!(found, [(5, 14, ": ?User", "type")]);
assert_eq!(hints[0].tooltip.as_deref(), Some("user() returns ?User"));
assert!(!hints[0].padding_left && !hints[0].padding_right);
}
#[test]
fn only_declared_languages_are_asked() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
assert!(host.provide_inlay_hints(&doc(tmp.path(), "javascript", ORDER), all()).is_empty());
}
#[test]
fn the_kind_needs_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let pkg = tmp.path().join("old");
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
}
-394
View File
@@ -1,394 +0,0 @@
//! Indexed names use the same preview/apply/undo commands as language-server refactorings.
use ide_core::{IdeEvent, Pos, RequestId, error::ErrorKind};
use ide_host::{
Host, HostConfig, ops,
plugins::{Consent, PluginHost, navigation::Snapshot, set_editor_providers},
};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use std::{path::Path, sync::Arc};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let first = root.join("definition.idx");
let second = root.join("usage.idx");
let source = "def 😀route\nref 😀route\n";
let usage = "ref 😀route\nref other\n";
std::fs::write(&first, source).unwrap();
std::fs::write(&second, usage).unwrap();
let events = Arc::new(Mutex::new(vec![]));
let captured = events.clone();
let host = 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(move |_| {
let events = captured.clone();
Arc::new(move |e| events.lock().push(e))
}),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_light("main", &first).unwrap();
// Editors retain ordinary Windows paths; index snapshots use canonical
// (verbatim on Windows) paths and the host's own project-root identity.
let doc = project.documents.open(dunce::simplified(&first)).unwrap();
let plugins =
Arc::new(PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap());
plugins.set_project_policy(Arc::new(|_| true));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
// Two independently published graphs may contain exactly the same anchors.
let duplicate = root.join("duplicate");
std::fs::create_dir_all(duplicate.join("dist")).unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), duplicate.join("dist/plugin.wasm")).unwrap();
std::fs::write(
duplicate.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml")).unwrap().replace("test.indexer", "test.duplicate"),
)
.unwrap();
plugins.install_from_dir(&duplicate, &Consent::all()).unwrap();
set_editor_providers(Some(&plugins));
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
set_editor_providers(None);
}
}
let _clear = Clear;
// LSP-managed library documents do not belong to Documents. Optional
// index lookup must not replace their native result with a missing-doc error.
let virtual_doc = ide_core::DocId(1_000_000);
project.lsp.did_open_virtual(
virtual_doc,
"jdt://contents/test/Route.class",
&ide_core::LanguageId::new("java"),
0,
"class Route {}",
);
let virtual_result = ops::lsp::lsp_references(
&host,
"main",
RequestId(99),
virtual_doc,
Pos::new(0, 6),
false,
false,
false,
ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert_eq!(virtual_result.kind, ErrorKind::Unavailable, "retain the LSP's native result for virtual documents");
project.lsp.did_close(virtual_doc);
let index = || {
for (path, text, version) in [(&first, source, Some(doc.version)), (&second, usage, None)] {
plugins
.index_navigation(
&project.root,
Snapshot { path, text, version, symbols: &[], scope: "app" },
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
}
};
index();
for pos in [Pos::new(0, 4), Pos::new(1, 5)] {
let prepared = ops::lsp::lsp_prepare_rename(&host, "main", doc.id, pos).await.unwrap().unwrap();
assert_eq!((prepared.start.col, prepared.end.col), (4, 11));
let partial = Arc::new(Mutex::new(vec![]));
let captured = partial.clone();
let refs = ops::lsp::lsp_references(
&host,
"main",
RequestId(1),
doc.id,
pos,
false,
true,
false,
ops::Channel::new(move |r| captured.lock().push(r)),
)
.await
.unwrap();
assert_eq!(refs.locations.len(), 3, "definitions and references, deduplicated across plugins");
assert_eq!(partial.lock()[0].locations.len(), 3);
}
assert!(
ops::lsp::lsp_prepare_rename(&host, "main", doc.id, Pos::new(0, 0)).await.is_err(),
"unowned text falls through to the unavailable LSP"
);
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(2), doc.id, Pos::new(0, 4), "new.route".into()).await.unwrap();
assert!(renamed.preview);
assert_eq!(renamed.edit.files.len(), 2);
assert_eq!(renamed.edit.preconditions.len(), 2);
assert_eq!(std::fs::read_to_string(&first).unwrap(), source, "creating/cancelling preview changes nothing");
assert_eq!(std::fs::read_to_string(&second).unwrap(), usage);
let opened_usage = project.documents.open(&second).unwrap();
let changed_version = project
.documents
.edit(
opened_usage.id,
opened_usage.version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 11, insert: "changed".into() }] },
)
.unwrap();
assert_eq!(
ops::lsp::lsp_rename(&host, "main", RequestId(20), doc.id, Pos::new(0, 4), "new.route".into())
.await
.unwrap_err()
.kind,
ErrorKind::OutOfSync,
"a closed-file snapshot cannot hide newer unsaved contents"
);
project
.documents
.edit(
opened_usage.id,
changed_version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 11, insert: "😀route".into() }] },
)
.unwrap();
project.documents.close(opened_usage.id);
// A closed file changing during preview rejects the whole operation.
std::fs::write(&second, "ref changed\n").unwrap();
assert_eq!(
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit.clone()).await.unwrap_err().kind,
ErrorKind::OutOfSync
);
assert_eq!(std::fs::read_to_string(&first).unwrap(), source);
assert_eq!(
ops::lsp::lsp_rename(&host, "main", RequestId(3), doc.id, Pos::new(0, 4), "new.route".into())
.await
.unwrap_err()
.kind,
ErrorKind::OutOfSync,
"stale closed-file anchors cannot be renamed"
);
std::fs::write(&second, usage).unwrap();
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(4), doc.id, Pos::new(1, 4), "new.route".into()).await.unwrap();
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "def new.route\nref new.route\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "ref new.route\nref other\n");
let id = events
.lock()
.iter()
.find_map(|e| match e {
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), source);
assert_eq!(std::fs::read_to_string(&second).unwrap(), usage);
ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&second).unwrap(), "ref new.route\nref other\n");
// A matching native preparation owns the decision, including a null rename.
std::fs::write(&first, source).unwrap();
std::fs::write(&second, usage).unwrap();
project.documents.close(doc.id);
let doc = project.documents.open(&first).unwrap();
for (path, text, version) in [(&first, source, Some(doc.version)), (&second, usage, None)] {
plugins
.index_navigation(
&project.root,
Snapshot { path, text, version, symbols: &[], scope: "app" },
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
}
native_rename_refusal(&host, &project, doc.id, &first, source).await;
plugins.set_enabled("test.indexer", false).unwrap();
plugins.set_enabled("test.duplicate", false).unwrap();
assert!(plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 4), Some("main")).is_none());
plugins.set_enabled("test.indexer", true).unwrap();
project.documents.close(doc.id);
let invalid = "def 😀\nsymbols \n";
std::fs::write(&first, invalid).unwrap();
let doc = project.documents.open(&first).unwrap();
let half_surrogate = ide_language::SymbolTag {
name: "broken".into(),
kind: ide_core::SymbolKind::Method,
container: None,
range: ide_core::Range::new(Pos::new(0, 4), Pos::new(0, 5)),
};
plugins
.index_navigation(
&project.root,
Snapshot {
path: &first,
version: Some(doc.version),
text: invalid,
symbols: &[half_surrogate],
scope: "app",
},
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
assert_eq!(
ops::lsp::lsp_prepare_rename(&host, "main", doc.id, Pos::new(0, 4)).await.unwrap_err().kind,
ErrorKind::InvalidInput,
"malformed UTF-16 anchors cannot split a character"
);
project.documents.close(doc.id);
host.set_project_trust(&root, true, false).await.unwrap();
let project = host.open_project("main", &root).unwrap();
// BOM-less UTF-16 detection requires mostly ASCII pairs, even with emoji anchors.
let padding = format!("ignored {}\n", "x".repeat(128));
let encoded_source = format!("{source}{padding}");
let encoded_usage = format!("{usage}{padding}");
for (encoding, bom) in
[("utf-8", true), ("utf-16le", true), ("utf-16be", true), ("utf-16le", false), ("utf-16be", false)]
{
ide_vfs::save_text(&first, &encoded_source, encoding, bom).unwrap();
ide_vfs::save_text(&second, &encoded_usage, encoding, bom).unwrap();
let original_second = std::fs::read(&second).unwrap();
let doc = project.documents.open(&first).unwrap();
assert_eq!(doc.text, encoded_source, "decoded {encoding} BOM={bom}");
// Index open, close, then validate that decoded text retains the same hash.
let usage_doc = project.documents.open(&second).unwrap();
ide_host::plugins::index_ops::plugin_navigation_index(
&host,
plugins.clone(),
"main",
vec![first.clone(), second.clone()],
RequestId(100),
)
.await
.unwrap();
project.documents.close(usage_doc.id);
assert!(
plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 5), Some("main")).is_some(),
"{encoding} BOM={bom} must publish the open definition"
);
let refs = ops::lsp::lsp_references(
&host,
"main",
RequestId(101),
doc.id,
Pos::new(0, 5),
false,
false,
false,
ops::Channel::new(|_| {}),
)
.await
.unwrap();
assert_eq!(refs.locations.len(), 3, "open-then-closed {encoding} BOM={bom}");
plugins.invalidate_client_navigation(&project.root, Some("main"), Some(std::slice::from_ref(&second)));
ide_host::plugins::index_ops::plugin_navigation_index(
&host,
plugins.clone(),
"main",
vec![second.clone()],
RequestId(102),
)
.await
.unwrap();
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(103), doc.id, Pos::new(0, 5), "new😀".into()).await.unwrap();
assert_eq!(renamed.edit.preconditions.len(), 2);
assert_eq!(std::fs::read(&second).unwrap(), original_second, "preview preserves bytes");
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit).await.unwrap();
assert_eq!(
std::fs::read(&second).unwrap(),
ide_vfs::encode(&format!("ref new😀\nref other\n{padding}"), encoding, bom).unwrap(),
"atomic rename preserves {encoding} BOM={bom}"
);
assert_eq!(project.documents.load_file(&first).unwrap().text, format!("def new😀\nref new😀\n{padding}"));
project.documents.close(doc.id);
}
}
async fn native_rename_refusal(
host: &Host,
project: &ide_host::Project,
doc: ide_core::DocId,
path: &Path,
text: &str,
) {
use ide_jsonrpc::{Handler, Peer};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU8, Ordering};
struct Native(Arc<AtomicU8>);
impl Handler for Native {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
let mode = self.0.load(Ordering::SeqCst);
Ok(match method {
"initialize" => {
json!({"capabilities": {"textDocumentSync": 1, "renameProvider": {"prepareProvider": true}}})
}
"textDocument/prepareRename" if mode < 2 => {
json!({"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 11}})
}
"textDocument/prepareRename" if mode == 3 => {
json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 3}})
}
"textDocument/rename" if mode == 1 => json!({"changes": {}}),
_ => Value::Null,
})
}
}
let mode = Arc::new(AtomicU8::new(0));
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
let accepted = peers.clone();
let state = mode.clone();
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (reader, writer) = stream.into_split();
accepted.lock().push(Peer::connect(reader, writer, Arc::new(Native(state.clone()))));
}
});
let config: ide_language::LanguageServerConfig = toml::from_str(&format!(
"name = 'rename-refusal'\ncommand = 'no-process-required'\nconnect = '127.0.0.1:{port}'"
))
.unwrap();
project.lsp.set_servers_hook(Box::new(move |_, _| vec![config.clone()]));
project.lsp.did_open(doc, path, &ide_core::LanguageId::plain_text(), 0, text);
for value in 0..4 {
mode.store(value, Ordering::SeqCst);
let renamed =
ops::lsp::lsp_rename(host, "main", RequestId(110 + value as u32), doc, Pos::new(0, 5), "new".into()).await;
if value < 2 {
assert_eq!(
renamed.unwrap_err().kind,
ErrorKind::InvalidInput,
"matching owner declines via null/empty edit"
);
} else {
let renamed = renamed.unwrap();
assert!(renamed.preview);
assert_eq!(renamed.edit.files.len(), 2, "unowned native range keeps indexed fallback");
}
}
project.lsp.did_close(doc);
project.lsp.shutdown_all().await;
project.lsp.set_servers_hook(Box::new(|_, _| vec![]));
task.abort();
let _ = task.await;
for peer in peers.lock().drain(..) {
peer.kill();
}
}
-232
View File
@@ -1,232 +0,0 @@
//! The synchronous workspace import uses the real project index, also from a provider.
use ide_core::CancellationToken;
use ide_core::{NullSink, Pos, Range};
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, PluginWorkspace};
use ide_host::{Host, HostConfig};
use ide_plugin::{Invocation, Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
const WAIT: Duration = Duration::from_secs(30);
struct Fixture {
_tmp: tempfile::TempDir,
_runtime: tokio::runtime::Runtime,
host: Arc<Host>,
plugins: PluginHost,
root: PathBuf,
other: PathBuf,
}
impl Fixture {
fn new(capability: bool) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let (root, other) = (base.join("project"), base.join("other"));
for dir in [&root, &other] {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(root.join("Order.php"), "<?php class OrderController { public function item() {} }\n").unwrap();
std::fs::write(root.join("Main.java"), "class OrderService { void serve() {} }\n").unwrap();
std::fs::write(other.join("Order.php"), "<?php class OrderElsewhere {}\n").unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let syntax = OnceLock::new();
syntax.set(Arc::new(ide_language::Syntax::new())).ok().unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::new(syntax),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
// Restricted projects need no LSP/process startup for index queries.
{
let _guard = runtime.enter();
for (window, dir) in [("main", &root), ("other", &other)] {
let project = host.open_project(window, dir).unwrap();
assert!(project.index.wait_idle(WAIT));
}
}
let plugins = PluginHost::new(
base.join("plugins"),
Arc::new(|_: Option<&str>, _| {}),
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() },
)
.unwrap();
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
plugins.set_project_policy(Arc::new(|_| true));
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/symbols");
let package = base.join("fixture");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(source.join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(source.join("plugin.toml")).unwrap();
let manifest =
if capability { manifest } else { manifest.replace("required = [\"project.read\"]", "required = []") };
std::fs::write(package.join("plugin.toml"), manifest).unwrap();
plugins.install_from_dir(&package, &Consent::all()).unwrap();
Self { _tmp: tmp, _runtime: runtime, host, plugins, root, other }
}
fn invoke(&self, command: &str, root: &Path, query: &str) -> ide_core::IdeResult<Option<String>> {
self.plugins.run_command(
"test.symbols",
command,
Some(query),
Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() },
)
}
fn search(&self, query: &str) -> String {
self.invoke("symbols", &self.root, query).unwrap().unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
self.host.close_project("other");
}
}
#[test]
fn symbols_reach_commands_and_providers_without_a_language_server() {
let f = Fixture::new(true);
let all = f.search("Order|10");
assert!(all.contains("OrderController\tclass\t"), "{all}");
assert!(all.contains("OrderService\tclass\t"), "{all}");
assert!(!all.contains("OrderElsewhere"));
assert_eq!(f.search("OCo|1").lines().count(), 1);
assert!(f.search("OrderController.item|1").starts_with("item\tmethod\tOrderController\t"));
assert!(f.search("|10").is_empty());
assert!(f.search("Order|0").is_empty());
assert!(f.search("missing|10").is_empty());
assert!(f.search("Order|4294967295").contains("OrderController"));
assert!(
f.invoke("symbols", &f.root, &format!("{}|1", "é".repeat(129)))
.unwrap_err()
.message
.contains("256 UTF-8 bytes")
);
let other = f.invoke("symbols", &f.other, "Order|10").unwrap().unwrap();
assert!(other.contains("OrderElsewhere") && !other.contains("OrderController"));
for language in ["php", "java"] {
let targets = f.plugins.provide_definition(
&ProviderDocument {
path: f.root.join(if language == "php" { "Usage.php" } else { "Usage.java" }),
language: language.into(),
version: 1,
text: "OCo".into(),
},
Pos::new(0, 3),
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].path, f.root.join("Order.php"));
let indexed = HostWorkspace(Arc::downgrade(&f.host))
.symbols(&f.root, "OCo", 1, &CancellationToken::new(), Instant::now() + WAIT)
.unwrap();
assert_eq!(targets[0].range, indexed[0].location.range);
}
}
#[test]
fn reindex_and_closed_project_replace_results_and_invalidate_retained_handles() {
let f = Fixture::new(true);
f.invoke("remember", &f.root, "").unwrap();
let p = f.host.project("main").unwrap();
let path = f.root.join("Order.php");
std::fs::write(&path, "<?php /*😀*/ class UpdatedController {}\n").unwrap();
p.index.update(std::slice::from_ref(&path), &[]);
assert!(p.index.wait_idle(WAIT));
assert!(!f.search("Order|10").contains("OrderController"));
let symbols = HostWorkspace(Arc::downgrade(&f.host))
.symbols(&f.root, "UpdatedController", 1, &CancellationToken::new(), Instant::now() + WAIT)
.unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].location.range, Range::new(Pos::new(0, 19), Pos::new(0, 36)));
assert!(f.search("Updated|10").contains("0:19-0:36"));
std::fs::remove_file(&path).unwrap();
p.index.update(&[], &[path]);
assert!(p.index.wait_idle(WAIT));
assert!(f.search("Updated|10").is_empty());
f.host.close_project("main");
let error = f.invoke("remembered", &f.root, "Order|10").unwrap_err();
assert!(error.message.contains("no longer open"), "{error}");
}
#[test]
fn large_limits_are_capped_before_searching_the_index() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
let path = f.root.join("Bulk.java");
let text = (0..1005).map(|i| format!("class BulkSymbol{i} {{}}\n")).collect::<String>();
std::fs::write(&path, text).unwrap();
project.index.update(&[path], &[]);
assert!(project.index.wait_idle(WAIT));
assert_eq!(f.search("BulkSymbol|4294967295").lines().count(), 1000);
}
#[test]
fn symbol_queries_require_project_read_even_with_an_invocation_project() {
let f = Fixture::new(false);
let error = f.invoke("symbols", &f.root, "Order|10").unwrap_err();
assert!(error.message.contains("project.read"), "{error}");
assert!(
f.plugins
.provide_definition(
&ProviderDocument {
path: f.root.join("Usage.php"),
language: "php".into(),
version: 1,
text: "OCo".into(),
},
Pos::new(0, 3)
)
.is_empty()
);
}
#[test]
fn symbol_search_excludes_external_content_roots_before_ranking() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
std::fs::write(f.other.join("Exact.php"), "<?php class Order {}\n").unwrap();
project.index.set_scope(vec![f.other.clone()], Vec::new());
assert!(project.index.wait_idle(WAIT));
assert_eq!(project.index.find_symbols("OrderElsewhere", ide_index::SymbolFilter::All, 1).len(), 1);
assert!(f.search("OrderElsewhere|10").is_empty());
// A better external match must not displace a readable fuzzy match.
let result = f.search("Order|1");
assert!(result.contains("OrderController") || result.contains("OrderService"), "{result}");
}
#[test]
fn native_symbol_search_honours_cancellation_and_deadlines() {
let f = Fixture::new(true);
let workspace = HostWorkspace(Arc::downgrade(&f.host));
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
workspace.symbols(&f.root, "Order", 10, &cancel, Instant::now() + WAIT),
Err(ide_plugin::HostError::Cancelled)
);
let error = workspace.symbols(&f.root, "Order", 10, &CancellationToken::new(), Instant::now()).unwrap_err();
assert!(error.to_string().contains("timed out"), "{error}");
}
#[cfg(unix)]
#[test]
fn symbol_search_excludes_updates_through_escaping_symlink_ancestors() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
std::fs::write(f.other.join("Secret.php"), "<?php class SecretSymbol {}\n").unwrap();
let dir = f.root.join("linked");
std::os::unix::fs::symlink(&f.other, &dir).unwrap();
project.index.update(&[dir.join("Secret.php")], &[]);
assert!(project.index.wait_idle(WAIT));
assert!(f.search("Secret|1").is_empty());
}
File diff suppressed because it is too large. Load diff
+3 -25
View File
@@ -164,9 +164,6 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
invoke(&peer, "plugin_set_ui_locale", json!({ "tag": "fr" })).await.unwrap();
assert_eq!(plugins.host().ui_locale(), "fr");
// Saving a file that exists only on the server: Checkstyle reads that file.
let java = project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
@@ -233,7 +230,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for dir in [official("csv-editor"), fixture("jobs"), fixture("settings-pages")] {
for dir in [official("csv-editor"), fixture("jobs")] {
let info = desktop.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
@@ -269,8 +266,8 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs", "settings-pages"]).await;
assert_eq!(report.installed.len(), 3, "{report:?}");
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs"]).await;
assert_eq!(report.installed.len(), 2, "{report:?}");
// CSV Editor: the document is the server's.
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
@@ -329,24 +326,5 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
.await
.unwrap();
assert_eq!(selected, "/srv/src/a.txt");
for (scope, name) in [("global", "Global"), ("project", "Project")] {
let values = json!({ "settings-pages": { "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] } });
invoke(&peer, "settings_set", json!({ "scope": scope, "key": "plugins", "value": values.to_string() }))
.await
.unwrap();
}
for (scope, name) in
[(None, "Project"), (Some("global"), "Global"), (Some("project"), "Project"), (None, "Project")]
{
let mut args = json!({ "pluginId": "settings-pages", "commandId": "test", "arg": null, "editor": null });
if let Some(scope) = scope {
args["settingsScope"] = json!(scope);
}
let result = invoke(&peer, "plugin_run_command", args).await.unwrap();
let result: Value = serde_json::from_str(result.as_str().unwrap()).unwrap();
assert_eq!(result["profiles"][0]["name"], name);
assert_eq!(result["hasProject"], true);
}
plugins.host().shutdown();
}
-18
View File
@@ -211,24 +211,6 @@ fn persists_user_decisions_locations_revocation_and_one_time_migration() {
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
}
#[test]
fn an_inherited_folder_is_trusted_for_this_process_only() {
let tmp = tempfile::tempdir().unwrap();
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
let root = tmp.path().join("workspaces/app");
std::fs::create_dir_all(&root).unwrap();
let trust = TrustStore::load(&config, &data);
assert!(!trust.trusted(&root).unwrap());
trust.inherit(&root).unwrap();
assert!(trust.trusted(&root).unwrap());
assert!(!trust.trusted(root.parent().unwrap()).unwrap(), "only that folder");
assert!(trust.status(&root).unwrap().decided, "the trust prompt doesn't ask again");
assert!(trust.decisions().unwrap().projects.is_empty(), "no decision is recorded");
assert!(!TrustStore::load(&config, &data).trusted(&root).unwrap(), "not persisted");
trust.decide(&root, false, false).unwrap();
assert!(!trust.trusted(&root).unwrap(), "a decision to restrict it still wins");
}
#[cfg(unix)]
fn script(root: &Path, name: &str, marker: &str) {
use std::os::unix::fs::PermissionsExt;
-37
View File
@@ -268,43 +268,6 @@ impl ProjectIndex {
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
}
/// Scoped symbol search with cooperative interruption, including lock waits.
/// `None` means interrupted; no partial results are returned.
pub fn find_symbols_until(
&self,
query: &str,
filter: SymbolFilter,
limit: usize,
include: &(impl Fn(&Path) -> bool + Sync),
stopped: &(impl Fn() -> bool + Sync),
) -> Option<Vec<SymbolMatch>> {
let recent = loop {
if stopped() {
return None;
}
if let Some(recent) = self.shared.recent.try_lock_for(Duration::from_millis(1)) {
break recent.clone();
}
};
let data = loop {
if stopped() {
return None;
}
if let Some(data) = self.shared.data.try_read_for(Duration::from_millis(1)) {
break data;
}
};
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"`.
pub fn symbols_named(&self, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
+10 -44
View File
@@ -121,35 +121,22 @@ fn scan(
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Vec<Cand> {
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
}
fn scan_until(
data: &IndexData,
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
stopped: &(impl Fn() -> bool + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Option<Vec<Cand>> {
data.files
.par_iter()
.enumerate()
.with_min_len(256)
.try_fold(Vec::new, |mut acc, (slot, e)| {
if stopped() {
return None;
}
.fold(Vec::new, |mut acc, (slot, e)| {
if let Some(e) = e {
per_file(slot as u32, e, &mut acc);
if acc.len() >= pool * 2 {
acc = keep_best(acc, pool, tiebreak);
}
}
Some(acc)
acc
})
.try_reduce(Vec::new, |mut a, b| {
.reduce(Vec::new, |mut a, b| {
a.extend(b);
Some(if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a })
if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a }
})
}
@@ -436,21 +423,9 @@ pub(crate) fn find_symbols(
filter: SymbolFilter,
limit: usize,
) -> Vec<SymbolMatch> {
find_symbols_until(data, recents, query, filter, limit, &|_| true, &|| false).unwrap_or_default()
}
pub(crate) fn find_symbols_until(
data: &IndexData,
recents: &Recents,
query: &str,
filter: SymbolFilter,
limit: usize,
include: &(impl Fn(&Path) -> bool + Sync),
stopped: &(impl Fn() -> bool + Sync),
) -> Option<Vec<SymbolMatch>> {
let q: String = query.trim().chars().filter(|c| !c.is_whitespace()).collect();
if q.is_empty() || limit == 0 {
return Some(Vec::new());
return Vec::new();
}
let (container_q, name_q) = split_symbol_query(&q);
let name = Needle::new(name_q);
@@ -461,16 +436,13 @@ pub(crate) fn find_symbols_until(
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, e, acc| {
if e.symbols.is_empty() || !include(&e.path) {
let cands = scan(data, pool, &tiebreak, |slot, e, acc| {
if e.symbols.is_empty() {
return;
}
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;
}
@@ -479,15 +451,11 @@ pub(crate) fn find_symbols_until(
}
}
});
})?;
if stopped() {
return None;
}
});
let mut out: Vec<(u32, Cand, Vec<u32>)> = with_matcher(|m| {
let mut buf = Vec::new();
cands
.into_iter()
.take_while(|_| !stopped())
.filter_map(|c| {
let s = sym(&c);
let e = file(data, c.slot);
@@ -504,8 +472,7 @@ pub(crate) fn find_symbols_until(
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()
out.into_iter()
.map(|(score, c, indices)| {
let e = file(data, c.slot);
let s = &e.symbols[c.sym as usize];
@@ -520,8 +487,7 @@ pub(crate) fn find_symbols_until(
indices,
}
})
.collect();
(!stopped()).then_some(results)
.collect()
}
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
-77
View File
@@ -652,80 +652,3 @@ fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
assert!(symbols.iter().any(|s| s.name == "find"));
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
}
#[test]
fn symbol_search_can_stop_during_scanning_and_lock_waits() {
use std::sync::atomic::AtomicUsize;
let fx = sample();
let idx = fx.open();
let calls = AtomicUsize::new(0);
let stopped = || calls.fetch_add(1, Ordering::Relaxed) >= 5;
assert!(idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &stopped).is_none());
assert!(calls.load(Ordering::Relaxed) > 5);
for recent in [false, true] {
let _data = (!recent).then(|| idx.shared.data.write());
let _recent = recent.then(|| idx.shared.recent.lock());
let deadline = std::time::Instant::now() + Duration::from_millis(10);
assert!(
idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &|| std::time::Instant::now() >= deadline)
.is_none()
);
}
}
#[test]
fn scoped_symbol_search_preserves_fuzzy_ranking_within_allowed_paths() {
let fx = sample();
let idx = fx.open();
let allowed = fx.root.join("src/order");
let symbols =
idx.find_symbols_until("OCo", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false).unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "OrderController");
assert!(
idx.find_symbols_until("OtherCollection", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false)
.unwrap()
.is_empty()
);
}
#[cfg(unix)]
#[test]
fn incremental_symbols_resolve_symlink_ancestors_before_scope_checks() {
let fx = Fixture::new(&[("src/inside.rs", "fn inside_symbol() {}\n")]);
let outside = fx._dir.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("secret.rs"), "fn secret_symbol() {}\n").unwrap();
std::os::unix::fs::symlink(&outside, fx.root.join("escape")).unwrap();
std::os::unix::fs::symlink(fx.root.join("src"), fx.root.join("alias")).unwrap();
let idx = fx.open();
idx.update(&[fx.root.join("escape/secret.rs"), fx.root.join("alias/inside.rs")], &[]);
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "secret_symbol", SymbolFilter::All).is_empty());
let inside = idx.find_symbols("inside_symbol", SymbolFilter::All, 10);
assert_eq!(inside.len(), 1);
assert_eq!(inside[0].path.0, fx.root.join("src/inside.rs"));
}
#[cfg(unix)]
#[test]
fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
let fx = Fixture::new(&[("src/old.rs", "fn old_symbol() {}\n")]);
drop(fx.open());
let outside = fx._dir.path().join("outside");
std::fs::rename(fx.root.join("src"), &outside).unwrap();
std::os::unix::fs::symlink(outside, fx.root.join("src")).unwrap();
let initial = Arc::new(Mutex::new(Vec::new()));
let events = initial.clone();
let sink: SharedSink = Arc::new(move |event: IdeEvent| {
if let IdeEvent::IndexState { symbols, .. } = event {
events.lock().push(symbols);
}
});
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
let idx = ProjectIndex::open(&fx.root, &fx.cache, Vec::new(), Vec::new(), languages, Arc::new(Syntax::new()), sink)
.unwrap();
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "old_symbol", SymbolFilter::All).is_empty());
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
+2 -8
View File
@@ -174,10 +174,7 @@ impl Shared {
Ok(records) => {
let entries: Vec<FileEntry> = records
.into_par_iter()
.filter(|(rel, _)| {
let path = root.join(rel);
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
})
.filter(|(rel, _)| !filter.is_excluded(&root.join(rel), false))
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
@@ -423,10 +420,7 @@ impl Shared {
let mut seen = HashSet::new();
let mut by_dir: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
for path in changed {
// A watcher path may have a symlink ancestor even when its spelling
// is inside the root. Resolve it on the worker before reading it.
let Ok(path) = dunce::canonicalize(&path) else { continue };
if !filter.contains(&path) || !seen.insert(path.clone()) {
if !seen.insert(path.clone()) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
+1 -1
View File
@@ -346,7 +346,7 @@ impl Drop for KillOnDrop {
/// child's `PATH` (a caller's `env` PATH, else the login shell's, else ours), so
/// that on Windows `PATHEXT` names (`codex` → `codex.cmd`) start; anything else
/// is kept as given.
pub fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
let path = std::path::Path::new(command);
if path.components().count() > 1 || path.is_absolute() {
return path.to_path_buf();
@@ -22,49 +22,8 @@ impl Syntax {
if language.as_str() == "properties" {
return Ok(properties(text));
}
if language.as_str() == "xml" {
// Only Spring wiring that mentions property placeholders can declare
// a configuration key; skip parsing unrelated XML (POMs, mappers,
// manifests) during project scans.
if !text.contains("<prop") && !text.contains("placeholder") && !text.contains("Placeholder") {
return Ok(Vec::new());
}
return self.with_parsed(language, text, cancelled, |tree| tree.spring_xml_keys(cancelled));
}
self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled))
}
/// Name of the Spring `${name[:default]}` placeholder covering `pos` in
/// `text`, if the position lies anywhere inside its braces. The name stops
/// at the first `:` or `}`; inside a nested default (`${a:${b}}`) the inner
/// braces name `b`. Empty names (`${}`) name nothing.
pub fn configuration_placeholder(&self, text: &str, pos: Pos) -> Option<String> {
let at = byte_at(text, pos)?;
let mut braces = Vec::new();
let mut best: Option<(usize, usize)> = None;
for (i, byte) in text.bytes().enumerate() {
match byte {
b'{' => braces.push((i, None, i > 0 && text.as_bytes()[i - 1] == b'$')),
b':' => {
if let Some((_, end, true)) = braces.last_mut() {
end.get_or_insert(i);
}
}
b'}' => {
if let Some((open, end, true)) = braces.pop() {
let open = open - 1;
if open <= at && at <= i && best.is_none_or(|(previous, _)| open > previous) {
best = Some((open, end.unwrap_or(i)));
}
}
}
_ => {}
}
}
let (open, end) = best?;
let name = text[open + 2..end].trim();
(!name.is_empty()).then(|| name.to_owned())
}
}
/// Spring's property lookups whose first argument names a configuration key.
@@ -109,134 +68,6 @@ impl ParsedTree<'_> {
}
out
}
/// Spring property declarations in XML wiring: `<prop key="name">` values
/// (util:properties, configurer `<props>`) and `<property name="name"
/// value="...">` entries under a placeholder configurer bean. Elements that
/// only reference a property file (`<context:property-placeholder>`,
/// `<util:properties location="...">`) declare no key of their own; the
/// configuration scan finds the keys inside the referenced files.
pub fn spring_xml_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
let mut out = Vec::new();
spring_xml(self.tree.root_node(), self.text, &LineIndex::new(self.text), &mut out, cancelled, 0, false);
out
}
}
/// Bean classes whose `<property name="key" value="...">` entries feed
/// placeholder resolution (the placeholder configurers).
fn placeholder_bean(class: &str) -> bool {
class.to_ascii_lowercase().contains("placeholder")
}
fn local_name(tag: &str) -> &str {
tag.rsplit(':').next().unwrap_or(tag)
}
fn spring_xml(
node: Node<'_>,
text: &str,
lines: &LineIndex<'_>,
out: &mut Vec<ConfigurationKey>,
cancelled: &dyn Fn() -> bool,
depth: usize,
configurer: bool,
) {
if depth > 64 || cancelled() || out.len() >= 20_000 {
return;
}
if node.kind() != "element" {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
spring_xml(child, text, lines, out, cancelled, depth + 1, configurer);
}
return;
}
let mut cursor = node.walk();
let children: Vec<_> = node.named_children(&mut cursor).collect();
let mut tag = "";
let mut inside = configurer;
let mut content: Vec<Node<'_>> = Vec::new();
for child in children {
match child.kind() {
"STag" | "EmptyElemTag" => {
let mut attrs_cursor = child.walk();
let attrs: Vec<_> = child.named_children(&mut attrs_cursor).collect();
let mut attributes: Vec<(&str, String, Range)> = Vec::new();
for (i, attr) in attrs.iter().enumerate() {
if i == 0 && attr.kind() == "Name" {
tag = local_name(&text[attr.byte_range()]);
continue;
}
if attr.kind() != "Attribute" {
continue;
}
let mut parts_cursor = attr.walk();
let mut name = "";
let mut value = None;
for part in attr.named_children(&mut parts_cursor) {
match part.kind() {
"Name" => name = &text[part.byte_range()],
"AttValue" => value = Some(quoted_value(part, text, lines)),
_ => {}
}
}
if let Some((value, range)) = value {
attributes.push((name, value, range));
}
}
let attribute = |wanted: &str| attributes.iter().find(|(name, _, _)| *name == wanted);
if tag == "prop"
&& let Some((_, key, range)) = attribute("key")
&& !key.is_empty()
&& key.len() <= 1024
{
out.push(ConfigurationKey { name: key.clone(), range: *range });
}
if tag == "property"
&& configurer
&& let (Some((_, name, range)), Some(_)) = (attribute("name"), attribute("value"))
&& !name.is_empty()
&& name.len() <= 1024
{
out.push(ConfigurationKey { name: name.clone(), range: *range });
}
if tag == "bean"
&& let Some((_, class, _)) = attribute("class")
{
inside = placeholder_bean(class);
}
}
"content" => content.extend(child.named_children(&mut cursor).filter(|n| n.kind() == "element")),
// Error recovery can nest elements without a `content` node.
"element" => content.push(child),
_ => {}
}
}
for element in content {
spring_xml(element, text, lines, out, cancelled, depth + 1, inside);
}
}
/// The decoded text of an `AttValue` and the range of its quoted contents.
fn quoted_value(node: Node<'_>, text: &str, lines: &LineIndex<'_>) -> (String, Range) {
let raw = &text[node.byte_range()];
let quoted = raw.len() >= 2
&& ((raw.starts_with('"') && raw.ends_with('"')) || (raw.starts_with('\'') && raw.ends_with('\'')));
let (value, range) = if quoted {
(&raw[1..raw.len() - 1], lines.range(node.start_byte() + 1, node.end_byte() - 1))
} else {
(raw, lines.range(node.start_byte(), node.end_byte()))
};
(unescape_xml(value), range)
}
/// XML's five predefined entities; unknown references keep their text.
fn unescape_xml(value: &str) -> String {
if !value.contains('&') {
return value.to_owned();
}
value.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
fn yaml(
@@ -486,107 +317,4 @@ class A {
);
assert_eq!(keys[0].range.end.line, 2);
}
#[test]
fn placeholder_names_cover_the_whole_reference() {
let text = "name: ${db.url:8080}\nplain: value\nempty: ${}\nnested: ${a:${b.c}}\nspaced: ${ db.h }\n";
let syntax = Syntax::new();
let at = |needle: &str, offset: u32| {
let line = text.lines().position(|l| l.contains(needle)).unwrap();
let line_text = text.lines().nth(line).unwrap();
let byte = line_text.find(needle).unwrap();
let col = line_text[..byte].encode_utf16().count() as u32 + offset;
syntax.configuration_placeholder(text, Pos::new(line as u32, col))
};
// On the `$`, inside the name and on the default all name `db.url`.
assert_eq!(at("${db.url", 0), Some("db.url".into()));
assert_eq!(at("db.url", 1), Some("db.url".into()));
assert_eq!(at("8080", 1), Some("db.url".into()));
assert_eq!(at("plain", 1), None);
assert_eq!(at("empty", 3), None);
// A nested default names its own property; the outer braces name `a`.
assert_eq!(at("${b.c", 2), Some("b.c".into()));
assert_eq!(at("${a:", 2), Some("a".into()));
assert_eq!(at("db.h", 1), Some("db.h".into()));
assert_eq!(syntax.configuration_placeholder("no placeholder", Pos::new(0, 3)), None);
assert_eq!(syntax.configuration_placeholder("trailing ${open", Pos::new(0, 12)), None);
}
#[test]
fn nested_placeholder_defaults_are_brace_balanced() {
let syntax = Syntax::new();
let text = "${outer:${inner}suffix}";
for col in 0..text.len() {
let expected = if (8..=15).contains(&col) { "inner" } else { "outer" };
assert_eq!(
syntax.configuration_placeholder(text, Pos::new(0, col as u32)).as_deref(),
Some(expected),
"column {col}"
);
}
let text = "${outer:${middle:${inner}}suffix}";
for (needle, expected) in [("outer", "outer"), ("middle", "middle"), ("inner", "inner"), ("suffix", "outer")] {
assert_eq!(
syntax.configuration_placeholder(text, Pos::new(0, text.find(needle).unwrap() as u32)).as_deref(),
Some(expected)
);
}
assert_eq!(
syntax.configuration_placeholder("${outer:{default}suffix}", Pos::new(0, 19)).as_deref(),
Some("outer")
);
assert_eq!(syntax.configuration_placeholder("${outer:${inner}suffix", Pos::new(0, 18)), None);
}
#[test]
fn configuration_placeholders_use_utf16_columns() {
let syntax = Syntax::new();
for text in ["message=你好😀 ${db.user}", "message: 你好😀 ${db.user}", "<bean value=\"你好😀 ${db.user}\"/>"]
{
let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32;
assert_eq!(syntax.configuration_placeholder(text, Pos::new(0, col + 2)).as_deref(), Some("db.user"));
}
}
#[test]
fn spring_xml_declares_prop_and_configurer_property_keys() {
let text = r#"<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans">
<context:property-placeholder location="classpath:db.properties"/>
<util:properties id="db">
<prop key="db.url">jdbc:h2:mem:test</prop>
</util:properties>
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="properties">
<props>
<prop key="db.user">sa</prop>
</props>
</property>
</bean>
<bean class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="url" value="jdbc:h2:mem"/>
</bean>
<bean class="org.springframework.beans.factory.config.PropertySourcesPlaceholderConfigurer">
<property name="system.mode" value="test"/>
</bean>
</beans>
"#;
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
// The configurer's own `properties` injection names no property, and a
// plain bean's `<property name="url">` is not an environment property.
assert_eq!(names, ["db.url", "db.user", "system.mode"]);
// The range covers the quoted key attribute's value.
let line = text.lines().position(|l| l.contains("db.url")).unwrap() as u32;
let start = keys[0].range.start;
assert_eq!((start.line, keys[0].range.end.line), (line, line));
assert_eq!(
&text.lines().nth(line as usize).unwrap()[start.col as usize..keys[0].range.end.col as usize],
"db.url"
);
}
#[test]
fn xml_without_property_markers_is_not_parsed() {
let text = "<mappers><mapper resource='a.xml'/><select id='x'>select 1</select></mappers>";
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
assert!(keys.is_empty());
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ impl ClientHandler {
}
};
let ended = matches!(&event, IdeEvent::Task { done: true, .. });
self.server.emit(mgr.sink(), event);
mgr.sink().emit(event);
self.server.refresh_busy_status(mgr.sink());
if ended {
self.server.progress_ended();
+5 -228
View File
@@ -41,12 +41,6 @@ const ACCESS_OPTIONS: [&str; 2] = [
"org.eclipse.jdt.core.compiler.problem.discouragedReference",
];
const LEVEL_OPTIONS: [&str; 3] = [
"org.eclipse.jdt.core.compiler.source",
"org.eclipse.jdt.core.compiler.compliance",
"org.eclipse.jdt.core.compiler.codegen.targetPlatform",
];
/// Keep the source model in protocol settings so jdtls's invisible-project
/// initialization and configuration use the same roots as workspace builds.
pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, paths: Option<Vec<String>>) {
@@ -60,115 +54,6 @@ pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, path
}
}
/// Applies Project Structure compiler output path, project libraries (and source attachments),
/// and Web facet `WEB-INF/lib` directories to jdtls `java.project.*` settings.
pub fn configure_project_structure(
cfg: &mut ide_language::LanguageServerConfig,
root: &Path,
server_root: &Path,
structure: &ide_core::settings::ProjectStructureSettings,
) {
if cfg.name != SERVER {
return;
}
let module_cfg = structure.module_settings(root, server_root);
let module_output = module_cfg.map(|m| m.compiler_output.trim()).filter(|s| !s.is_empty());
let output_path = if let Some(output) = module_output {
// The dialog stores paths relative to the project, JDT expects the server root.
let path = root.join(output);
match path.strip_prefix(server_root) {
Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")),
Err(_) => {
cfg.launch_error = Some(format!("Java compiler output must be inside its module: {}", path.display()));
None
}
}
} else {
let output = structure.compiler_output.trim();
if output.is_empty() {
None
} else if Path::new(output).is_absolute() {
match Path::new(output).strip_prefix(server_root) {
Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")),
Err(_) => {
cfg.launch_error = Some(format!("Java compiler output must be inside its module: {output}"));
None
}
}
} else {
Some(output.replace('\\', "/"))
}
};
let normalize = |entry: &str| -> Option<String> {
let trimmed = entry.trim();
if trimmed.is_empty() {
return None;
}
// JDT resolves relative library globs against its own root, which may be a submodule.
Some(root.join(trimmed).to_string_lossy().replace('\\', "/"))
};
let mut include: Vec<String> = Vec::new();
let mut sources = serde_json::Map::new();
for lib in &structure.libraries {
let class_entries: Vec<String> = lib.classes.iter().filter_map(|c| normalize(c)).collect();
let source_entries: Vec<String> = lib.sources.iter().filter_map(|s| normalize(s)).collect();
for c in &class_entries {
if !include.contains(c) {
include.push(c.clone());
}
}
for (class, source) in class_entries.iter().zip(&source_entries) {
sources.insert(class.clone(), json!(source));
}
}
for facet in &structure.facets {
if facet.kind == "web" && root.join(&facet.module) == server_root {
let web_root = facet.web_root.trim();
let web_root = if web_root.is_empty() { "src/main/webapp" } else { web_root };
if let Some(path) = normalize(web_root) {
let glob = format!("{}/WEB-INF/lib/**/*.jar", path.trim_end_matches('/'));
if !include.contains(&glob) {
include.push(glob);
}
}
}
}
if output_path.is_none() && include.is_empty() {
return;
}
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
if let Some(output) = output_path {
settings["java"]["project"]["outputPath"] = json!(output);
}
if !include.is_empty() {
let existing = &settings["java"]["project"]["referencedLibraries"];
let mut libraries = if existing.is_object() {
existing.clone()
} else if existing.is_array() {
json!({ "include": existing })
} else {
json!({ "include": ["lib/**/*.jar"] })
};
let mut entries = libraries["include"].as_array().cloned().unwrap_or_default();
for path in include {
let path = json!(path);
if !entries.contains(&path) {
entries.push(path);
}
}
libraries["include"] = json!(entries);
if !sources.is_empty() {
let mut attachments = libraries["sources"].as_object().cloned().unwrap_or_default();
attachments.extend(sources);
libraries["sources"] = json!(attachments);
}
settings["java"]["project"]["referencedLibraries"] = libraries;
}
cfg.settings = Some(settings);
}
pub(crate) fn source_paths_removed(
current: &ide_language::LanguageServerConfig,
next: &ide_language::LanguageServerConfig,
@@ -219,13 +104,11 @@ fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value {
let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) };
let mut options = serde_json::Map::new();
for line in prefs.lines() {
if let Some((key, value)) = line.split_once('=') {
let (k, v) = (key.trim(), value.trim());
if (ACCESS_OPTIONS.contains(&k) && ["error", "warning", "info", "ignore"].contains(&v))
|| (LEVEL_OPTIONS.contains(&k) && ["1.8", "11", "17", "21", "25"].contains(&v))
{
options.insert(k.into(), json!(v));
}
if let Some((key, value)) = line.split_once('=')
&& ACCESS_OPTIONS.contains(&key.trim())
&& ["error", "warning", "info", "ignore"].contains(&value.trim())
{
options.insert(key.trim().into(), json!(value.trim()));
}
}
Value::Object(options)
@@ -1095,110 +978,4 @@ mod tests {
assert!(missing_bundle(&IdeError::invalid("No delegateCommandHandler for vscode.java.startDebugSession")));
assert!(!missing_bundle(&e));
}
#[test]
fn configure_project_structure_sets_output_referenced_libraries_and_web_facets() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let lib_dir = root.join("lib");
std::fs::create_dir_all(&lib_dir).unwrap();
let jar_path = lib_dir.join("helper.jar");
let src_path = lib_dir.join("helper-sources.jar");
std::fs::write(&jar_path, b"").unwrap();
std::fs::write(&src_path, b"").unwrap();
let mut structure = ide_core::settings::ProjectStructureSettings {
compiler_output: "out/production".into(),
libraries: vec![ide_core::settings::ProjectLibrary {
name: "helper".into(),
classes: vec!["lib/helper.jar".into()],
sources: vec!["lib/helper-sources.jar".into()],
javadocs: vec![],
scope: ide_core::settings::DependencyScope::Compile,
}],
facets: vec![ide_core::settings::FacetConfig {
kind: "web".into(),
module: ".".into(),
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
web_root: "src/main/webapp".into(),
}],
..Default::default()
};
structure.modules.insert(
"submod".into(),
ide_core::settings::ModuleStructureSettings {
compiler_output: "submod/classes".into(),
..Default::default()
},
);
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
configure_project_structure(&mut cfg, root, root, &structure);
let settings = cfg.settings.as_ref().unwrap();
assert_eq!(settings.pointer("/java/project/outputPath").and_then(Value::as_str), Some("out/production"));
let include = settings
.pointer("/java/project/referencedLibraries/include")
.and_then(Value::as_array)
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
assert!(include.iter().any(|p| p.ends_with("lib/helper.jar")));
assert!(include.iter().any(|p| p.ends_with("src/main/webapp/WEB-INF/lib/**/*.jar")));
let sources = settings.pointer("/java/project/referencedLibraries/sources").and_then(Value::as_object).unwrap();
assert!(sources.values().any(|v| v.as_str().is_some_and(|s| s.ends_with("lib/helper-sources.jar"))));
// Module-specific compiler output overrides project-wide compiler output.
let mut sub_cfg: ide_language::LanguageServerConfig =
toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
configure_project_structure(&mut sub_cfg, root, &root.join("submod"), &structure);
assert_eq!(
sub_cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath").and_then(Value::as_str),
Some("classes")
);
let sub_settings = sub_cfg.settings.as_ref().unwrap();
let libs = sub_settings.pointer("/java/project/referencedLibraries/include").unwrap().as_array().unwrap();
assert!(libs.contains(&json!(root.join("lib/helper.jar").to_string_lossy().replace('\\', "/"))));
assert!(!libs.iter().any(|v| v.as_str().is_some_and(|p| p.contains("WEB-INF"))));
}
#[test]
fn project_libraries_preserve_existing_globs_exclusions_and_source_attachments() {
let tmp = tempfile::tempdir().unwrap();
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
cfg.settings = Some(json!({"java": {"project": {"referencedLibraries": {
"include": ["vendor/**/*.jar"], "exclude": ["vendor/old.jar"],
"sources": {"vendor/base.jar": "vendor/base-sources.jar"}
}}}}));
let structure = ide_core::settings::ProjectStructureSettings {
libraries: vec![ide_core::settings::ProjectLibrary {
classes: vec!["lib/new.jar".into()],
sources: vec!["lib/new-sources.jar".into()],
..Default::default()
}],
..Default::default()
};
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
let libraries = cfg.settings.as_ref().unwrap().pointer("/java/project/referencedLibraries").unwrap();
assert_eq!(libraries["include"][0], "vendor/**/*.jar");
assert_eq!(libraries["exclude"], json!(["vendor/old.jar"]));
assert_eq!(libraries["sources"]["vendor/base.jar"], "vendor/base-sources.jar");
assert_eq!(libraries["sources"].as_object().unwrap().len(), 2);
}
#[test]
fn absolute_output_inside_the_module_becomes_relative() {
let tmp = tempfile::tempdir().unwrap();
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
let mut structure = ide_core::settings::ProjectStructureSettings {
compiler_output: tmp.path().join("out").to_string_lossy().into_owned(),
..Default::default()
};
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
assert_eq!(cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath"), Some(&json!("out")));
structure.compiler_output = tmp.path().parent().unwrap().join("elsewhere").to_string_lossy().into_owned();
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
assert!(cfg.launch_error.as_deref().unwrap().contains("inside its module"));
}
}
+31 -130
View File
@@ -20,16 +20,6 @@ const MAX_CACHED_KEYS: usize = 100_000;
#[derive(Default)]
pub(crate) struct ConfigurationCache {
files: HashMap<PathBuf, CachedFile>,
roots: HashMap<PathBuf, CachedPaths>,
}
struct CachedPaths {
paths: Vec<PathBuf>,
incomplete: Option<ReferencesIncomplete>,
}
impl ConfigurationCache {
pub(crate) fn invalidate_paths(&mut self) {
self.roots.clear();
}
}
struct CachedFile {
stamp: (SystemTime, u64),
@@ -59,9 +49,6 @@ fn language(path: &Path) -> Option<LanguageId> {
match path.extension()?.to_str()? {
"yaml" | "yml" => Some(LanguageId::new("yaml")),
"properties" => Some(LanguageId::new("properties")),
// Spring XML wiring declares properties in `<prop key="...">` values
// and placeholder-configurer `<property name="...">` entries.
"xml" => Some(LanguageId::new("xml")),
_ => None,
}
}
@@ -171,16 +158,6 @@ impl LspManager {
} else {
None
};
// A click inside a Spring `${name}` placeholder sits on a value, not a
// key: the name names the property whose declaration lives in
// configuration files or Spring XML, with Java binding members still
// answering through the Java index.
let placeholder = if config && key.is_none() && !usages && snap.text.as_str().len() as u64 <= MAX_BYTES {
syntax().configuration_placeholder(snap.text.as_str(), pos)
} else {
None
};
let key = placeholder.clone().or(key);
if config && key.is_none() {
return Ok(None);
}
@@ -201,8 +178,7 @@ impl LspManager {
return Ok(None);
}
}
let result =
self.configuration_query(snap, pos, key, placeholder.is_some(), usages, include_declaration, cancel).await;
let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await;
match result {
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()),
Err(e) => {
@@ -213,63 +189,22 @@ impl LspManager {
}
}
#[allow(clippy::too_many_arguments)]
async fn configuration_query(
&self,
snap: &DocSnap,
pos: Pos,
key: Option<String>,
placeholder: bool,
usages: bool,
include_declaration: bool,
cancel: &CancellationToken,
) -> IdeResult<Option<References>> {
// A `${name}` reference resolves from configuration files and Spring
// XML without Java; those declarations are collected first so
// navigation survives an unavailable or outdated Java extension.
let local = if placeholder {
let name = key.clone().unwrap_or_default();
let path = snap.path.clone();
let project_root = self.project_root.clone();
let root = tokio::task::spawn_blocking(move || {
path.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|p| p.starts_with(&project_root))
.find(|p| {
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
.iter()
.any(|name| p.join(name).is_file())
})
.map(Path::to_path_buf)
.unwrap_or(project_root)
})
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
Some(
self.configuration_declarations(
vec![Pattern { name, relaxed: true }],
std::slice::from_ref(&root),
cancel,
)
.await?,
)
} else {
None
};
let answered = local.as_ref().is_some_and(|local| !local.locations.is_empty());
let (server, run) = match self.jdtls(&snap.path, cancel).await {
Ok(found) => found,
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()),
Err(_) if answered => return Ok(local),
Err(e) => return Err(e),
};
let (server, run) = self.jdtls(&snap.path, cancel).await?;
if !run.caps.read().commands().iter().any(|command| command == COMMAND) {
return Ok(if placeholder { local } else { None });
return Ok(None);
}
let target = Target { server: server.clone(), run };
let position = snap.text.ide_to_lsp(Encoding::Utf16, pos);
let response: Response = match self
let response: Response = self
.call(
&target,
"workspace/executeCommand",
@@ -279,17 +214,11 @@ impl LspManager {
Some(cancel),
crate::LONG_TIMEOUT,
)
.await
{
Ok(response) => response,
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()),
Err(_) if answered => return Ok(local),
Err(e) => return Err(e),
};
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if response.keys.is_empty() && !placeholder {
if response.keys.is_empty() {
return Ok(response
.incomplete
.then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() }));
@@ -300,18 +229,22 @@ impl LspManager {
..Default::default()
};
self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref()));
if let Some(local) = local {
result.locations.extend(local.locations);
result.incomplete = result.incomplete.or(local.incomplete);
}
if key.is_none() || usages || placeholder {
if key.is_none() || usages {
let roots: Vec<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
let patterns = if placeholder {
vec![Pattern { name: key.clone().unwrap_or_default(), relaxed: true }]
} else {
response.keys
};
let files = self.configuration_declarations(patterns, &roots, cancel).await?;
let overlays: HashMap<PathBuf, String> = self
.docs
.lock()
.values()
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
.collect();
let cache = self.configuration_cache.clone();
let cancelled = cancel.clone();
let keys = response.keys;
let files =
tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled))
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
result.locations.extend(files.locations);
result.incomplete = result.incomplete.or(files.incomplete);
}
@@ -333,29 +266,6 @@ impl LspManager {
crate::convert::dedup_locations(&mut result.locations);
Ok(Some(result))
}
/// Configuration-file and Spring-XML declarations matching `patterns` under
/// `roots`, with open editor contents taking precedence over disk.
async fn configuration_declarations(
&self,
patterns: Vec<Pattern>,
roots: &[PathBuf],
cancel: &CancellationToken,
) -> IdeResult<References> {
let overlays: HashMap<PathBuf, String> = self
.docs
.lock()
.values()
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
.collect();
let cache = self.configuration_cache.clone();
let cancelled = cancel.clone();
let roots = roots.to_vec();
tokio::task::spawn_blocking(move || configuration_files(&roots, &patterns, &overlays, &cache, &cancelled))
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))
}
}
fn in_module(root: &Path, path: &Path) -> bool {
@@ -386,15 +296,11 @@ fn configuration_files(
) -> References {
let mut result = References::default();
let mut paths = std::collections::BTreeSet::new();
let mut cache = cache.lock();
for root in roots {
if let Some(cached) = cache.roots.get(root) {
paths.extend(cached.paths.iter().cloned());
result.incomplete = result.incomplete.or(cached.incomplete);
continue;
}
let mut found = CachedPaths { paths: Vec::new(), incomplete: None };
let module = root.clone();
// Do not scan a source root twice when its enclosing module is also present.
let roots: Vec<_> =
roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect();
for root in &roots {
let module = (*root).clone();
let walker = ignore::WalkBuilder::new(root)
.follow_links(false)
.filter_entry(move |entry| {
@@ -418,26 +324,21 @@ fn configuration_files(
if cancel.is_cancelled() {
return result;
}
if visited >= 50_000 || found.paths.len() >= MAX_FILES {
found.incomplete = Some(ReferencesIncomplete::TooManyFiles);
if visited >= 50_000 || paths.len() >= MAX_FILES {
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
break;
}
match entry {
Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => {
found.paths.push(entry.into_path());
paths.insert(entry.into_path());
}
Err(_) => found.incomplete = Some(ReferencesIncomplete::FilesSkipped),
Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
_ => {}
}
}
paths.extend(found.paths.iter().cloned());
result.incomplete = result.incomplete.or(found.incomplete);
if cache.roots.len() >= MAX_FILES {
cache.roots.clear();
}
cache.roots.insert(root.clone(), found);
}
paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned());
let mut cache = cache.lock();
if cache.files.len() > MAX_FILES {
cache.files.clear();
}
+115 -99
View File
@@ -517,7 +517,7 @@ impl LspManager {
let Some(me) = self.me.upgrade() else { return };
if !server.root.is_dir() {
tracing::info!("{}: workspace root {} is gone, dropping", server.name, server.root.display());
server.begin_stop(&self.sink, true);
server.announce_down();
self.forget(std::slice::from_ref(server));
return;
}
@@ -526,10 +526,11 @@ impl LspManager {
}
async fn run_instance(self: Arc<Self>, server: Arc<Server>, generation: u64) {
let Some(cancel) = server.starting_status(generation, &self.sink) else { return };
server.set_status(&self.sink, ide_core::event::ServerStatus::Starting, None);
let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation));
let me = self.clone();
let tail = server.clone();
let cancel = server.start_token();
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
// servers may take a while to start listening.
let launched = tokio::task::spawn_blocking(move || {
@@ -540,7 +541,7 @@ impl LspManager {
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
tail.set_effective_config(generation, cfg.clone());
tail.set_effective_config(cfg.clone());
let prepare = me.prepare_hook.read().clone();
if !cancel.is_cancelled()
&& me.launcher.available(&cfg, &tail.root)
@@ -549,7 +550,7 @@ impl LspManager {
prepare(&cfg, &tail.root, &cancel);
cfg = me.configured(&tail);
}
tail.set_effective_config(generation, cfg.clone());
tail.set_effective_config(cfg.clone());
let root = tail.root.clone();
if let Some(message) = &cfg.launch_error {
// A missing binary still asks to be installed first.
@@ -559,23 +560,14 @@ impl LspManager {
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
me.launcher.launch_resolved(
&cfg,
&root,
handler,
Box::new(move |line| tail.push_stderr(generation, line)),
&cancel,
)
me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
})
.await
.unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string())));
let peer = match launched {
Ok((peer, binary)) => {
if !server.set_resolved_binary(generation, binary) {
peer.kill();
return;
}
*server.resolved_binary.write() = binary;
peer
}
Err(LaunchError::Missing) => {
@@ -657,10 +649,7 @@ impl LspManager {
};
let run =
Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() };
if !server.set_initializing(run.clone()) {
peer.kill();
return;
}
server.set_initializing(Some(run.clone()));
if let Err(e) = peer.notify("initialized", json!({})) {
tracing::warn!("{}: {e}", server.name);
}
@@ -681,6 +670,7 @@ impl LspManager {
let mut docs = self.docs.lock();
if !server.mark_ready(generation, run.clone()) {
drop(docs);
server.set_initializing(None);
peer.kill();
return;
}
@@ -716,20 +706,79 @@ impl LspManager {
}
async fn on_instance_closed(self: Arc<Self>, server: Arc<Server>, generation: u64, code: Option<i32>) {
if self.shutting_down.load(Ordering::Acquire) {
use ide_core::event::ServerStatus;
let intentional = match server.on_closed(generation) {
Some(intentional) => intentional || self.shutting_down.load(Ordering::Acquire),
None => return, // superseded instance
};
self.clear_server_state(&server);
self.file_watch_roots_changed();
if intentional {
server.announce_down();
return;
}
let Some(delay) = server.on_closed(generation, &self.sink, code) else { return };
self.file_watch_roots_changed();
// The workspace root gone means this server can never serve again:
// restarting would respawn with a dead working directory, so the entry
// is dropped instead of staying "Crashed" forever.
if !server.root.is_dir() {
server.announce_down();
self.forget(std::slice::from_ref(&server));
return;
}
if let Some(delay) = delay {
tokio::time::sleep(delay).await;
if server.is_idle_gen(generation) {
self.ensure_started(&server);
let tail = server.stderr_tail(5);
let cfg = server.effective_config();
let mut message = match (code, &cfg.connect) {
(Some(c), _) => format!("{} exited with code {c}", server.name),
(None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", server.name),
(None, None) => format!("{} terminated unexpectedly", server.name),
};
if !tail.is_empty() {
message.push_str(":\n");
message.push_str(&tail);
}
match server.record_crash() {
Some(delay) => {
server.set_status(&self.sink, ServerStatus::Crashed, Some(format!("{message}\nRestarting…")));
// Woken after the crash is reported: a request that finds no instance
// reads this status to say why. The restart sets `Starting` again.
server.announce_down();
tokio::time::sleep(delay).await;
if server.is_idle_gen(generation) {
self.ensure_started(&server);
}
}
None => {
server.give_up(generation);
server.set_status(
&self.sink,
ServerStatus::Crashed,
Some(format!("{message}\nCrashed repeatedly; restart it manually.")),
);
// Requests still waiting for a start read this status for their error.
server.announce_down();
}
}
}
/// Ends progress tasks and withdraws diagnostics of a dead instance.
fn clear_server_state(&self, server: &Server) {
for (id, title) in server.take_progress() {
self.sink.emit(ide_core::IdeEvent::Task {
id,
title,
message: None,
fraction: None,
done: true,
cancellable: false,
});
}
server.progress_ended();
for path in server.take_diagnostic_paths() {
self.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.into(),
source: server.name.clone(),
items: Vec::new(),
});
}
}
@@ -1336,8 +1385,14 @@ impl LspManager {
let mut first = Vec::new();
if method == "textDocument/implementation" {
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
if complete && !locs.is_empty() {
return Ok(locs);
// A type declared in the sources has no implementations in the dependencies:
// none found there is the whole answer, without a type hierarchy over every jar.
if complete {
if !locs.is_empty() {
return Ok(locs);
}
out.value = Some(Vec::new());
continue;
}
if let Some(partial) = partial
&& !locs.is_empty()
@@ -1395,9 +1450,9 @@ impl LspManager {
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
}
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
/// workspace's sources are searched (`libraries_skipped`); with it, those found
/// there arrive on `partial` before the dependencies and the JDK are searched.
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources are
/// searched (`libraries_skipped` when `project_wide`); with it and `project_wide`, those
/// found there arrive on `partial` before the dependencies and the JDK are searched.
#[allow(clippy::too_many_arguments)]
pub async fn references_streaming(
&self,
@@ -1426,20 +1481,7 @@ impl LspManager {
};
let native =
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
futures::pin_mut!(configuration, native);
let (configuration, result) = match futures::future::select(configuration, native).await {
futures::future::Either::Left((configuration, native)) => (configuration, native.await),
futures::future::Either::Right((result, configuration)) => {
// The configuration index is rebuilt after Java changes, which takes seconds in a
// project with many dependencies: the usages found meanwhile are shown first.
if let (Some(partial), Ok(found)) = (partial, &result)
&& !found.locations.is_empty()
{
partial(found.clone());
}
(configuration.await, result)
}
};
let (configuration, result) = futures::join!(configuration, native);
let configuration = configuration?;
let mut result = result?;
if let Some(configuration) = configuration {
@@ -1563,7 +1605,10 @@ impl LspManager {
};
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
} else {
let first = query(false).await?;
// Bulk probes (usage counts, Safe Delete) ask about the sources' own symbols,
// which no dependency uses: searching every jar and the JDK only adds seconds
// per symbol and delays the searches the user asks for.
let first = query(!libraries).await?;
let empty = first.is_empty();
scope::Widened::only(first, empty, &t.server)
};
@@ -2059,7 +2104,7 @@ impl LspManager {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(Renamed { preview: false, edit: out.finish(WorkspaceEdit::default())?, incomplete })
Ok(Renamed { edit: out.finish(WorkspaceEdit::default())?, incomplete })
}
/// The servers that can rename at `pos`, the first one when none can (a
@@ -2096,7 +2141,7 @@ impl LspManager {
let edit = query().await?;
let empty = edit.files.is_empty();
let w = scope::Widened::only((), empty, &t.server);
return Ok(Renamed { preview: false, edit, incomplete: w.incomplete });
return Ok(Renamed { edit, incomplete: w.incomplete });
}
let files = |e: &WorkspaceEdit| {
e.files
@@ -2122,7 +2167,7 @@ impl LspManager {
let files =
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
Ok(Renamed { preview: false, edit, incomplete: w.incomplete })
Ok(Renamed { edit, incomplete: w.incomplete })
}
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
@@ -2265,9 +2310,6 @@ impl LspManager {
/// Tells running servers that files or folders moved (`moves`: from → to, after the move).
pub fn did_rename_files(&self, moves: &[(PathBuf, PathBuf)]) {
if !moves.is_empty() {
self.configuration_cache.lock().invalidate_paths();
}
let moves: Vec<(PathBuf, PathBuf, bool)> =
moves.iter().map(|(f, t)| (f.clone(), t.clone(), t.is_dir())).collect();
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
@@ -2288,13 +2330,6 @@ impl LspManager {
/// into `.venv` or a `go.mod` rewritten by `go get` is picked up without a restart. Other
/// servers hear of their languages' files under their root; without it jdtls's incremental
/// build never sees them, so a launch runs stale classes.
///
/// Creates and changes for documents synced on a server's current instance are left out.
/// Their text reaches the server through `didOpen`/`didChange`, including clean-buffer
/// reloads. A server that re-reads the file on the watched change (csharp-ls) would apply
/// the reload and then the `didChange` computed against the pre-reload copy, so it would
/// keep analysing content that diverges from both the disk and the editor (#1780).
/// Deletions still reach it: document sync does not describe workspace membership.
pub fn watched_files_changed(
&self,
created: &[PathBuf],
@@ -2303,47 +2338,20 @@ impl LspManager {
excluded_dirs: &[PathBuf],
) {
use file_watch::Change;
if !created.is_empty() || !removed.is_empty() || !excluded_dirs.is_empty() {
self.configuration_cache.lock().invalidate_paths();
}
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
let open: HashMap<(ide_core::ServerId, u64), HashSet<PathBuf>> = {
let docs = self.docs.lock();
let mut open: HashMap<_, HashSet<_>> = HashMap::new();
for d in docs.values() {
for a in &d.servers {
if let Some(generation) = a.opened_gen {
open.entry((a.server.id, generation))
.or_default()
.extend([d.path.clone(), d.canonical.clone()]);
}
}
}
open
};
let canonical: HashMap<_, _> =
created.iter().chain(modified).filter_map(|p| dunce::canonicalize(p).ok().map(|c| (p, c))).collect();
for s in servers {
let Some(run) = s.running() else { continue };
let open = open.get(&(s.id, run.generation)).filter(|_| run.caps.read().sync != SyncKind::None);
let send = |p: &PathBuf, change: Change| {
change == Change::Deleted
|| !open
.is_some_and(|paths| paths.contains(p) || canonical.get(p).is_some_and(|c| paths.contains(c)))
};
let all = [(created, Change::Created), (modified, Change::Changed), (removed, Change::Deleted)]
.into_iter()
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
let watchers = run.watchers.read();
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
let languages = s.languages();
all.filter(|(p, change)| {
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
})
.collect()
all.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
.collect()
} else {
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
.filter(|(p, change)| watchers.wants(&s.root, p, *change))
.collect()
};
drop(watchers);
@@ -2917,7 +2925,7 @@ impl LspManager {
&& without_settings(&next) == without_settings(&current)
&& !jdtls::source_paths_removed(&current, &next)
{
s.set_effective_config(run.generation, next);
s.set_effective_config(next);
let params = json!({ "settings": handler::all_settings(&s.effective_config()) });
if let Err(e) = run.peer.notify("workspace/didChangeConfiguration", params) {
tracing::debug!("{}: didChangeConfiguration: {e}", s.name);
@@ -3056,10 +3064,11 @@ impl LspManager {
let conv = self.conv(encoding);
for (path, items) in server.diagnostics_by_path() {
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
server.emit(
&self.sink,
ide_core::IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items },
);
self.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.into(),
source: server.name.clone(),
items,
});
}
}
}
@@ -3107,14 +3116,20 @@ impl LspManager {
/// Gracefully stops the current instance (`shutdown` + `exit`, then kill).
async fn stop_instance(&self, server: &Arc<Server>, final_stop: bool) {
let Some(run) = server.begin_stop(&self.sink, final_stop) else { return };
let Some(run) = server.begin_stop() else {
if final_stop {
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
}
return;
};
let peer = run.peer.clone();
// A server we connected to without starting it (an open Godot editor) is not
// ours to shut down: only the connection closes.
if peer.pid().is_none() && server.effective_config().connect.is_some() {
peer.kill();
server.end_stop(run.generation, &self.sink);
self.file_watch_roots_changed();
server.end_stop(run.generation);
self.clear_server_state(server);
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
return;
}
let graceful = async {
@@ -3128,8 +3143,9 @@ impl LspManager {
let _ = tokio::time::timeout(Duration::from_secs(2), peer.closed()).await;
}
peer.kill();
server.end_stop(run.generation, &self.sink);
self.file_watch_roots_changed();
server.end_stop(run.generation);
self.clear_server_state(server);
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
}
pub async fn shutdown_all(&self) {
+1 -1
View File
@@ -191,6 +191,6 @@ impl LspManager {
) {
let conv = self.conv(enc);
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
}
}
+3 -30
View File
@@ -6,8 +6,8 @@
//! other project files are silently missing. So, as a word index would, the
//! project files of the server's languages that contain the symbol's name as a
//! whole word (found through the host's [`CandidateFn`]) and that the first
//! answer and the server's analysis scope did not cover are opened for a second
//! request, then closed again. Diagnostics the server publishes for them are dropped.
//! answer did not cover are opened on the server for a second request, then
//! closed again. Diagnostics the server publishes for them are dropped.
use crate::server::{Running, Server};
use crate::text::DocText;
@@ -29,8 +29,6 @@ pub const MAX_CANDIDATES: usize = 300;
const MAX_CANDIDATE_BYTES: u64 = 1 << 20;
/// How long a search that found nothing waits for a server to finish indexing.
const INDEX_WAIT: Duration = Duration::from_secs(10);
/// Optional server command: candidate URIs in, already analysed candidate URIs out.
const COVERED_FILES: &str = "openidea.workspace.coveredFiles";
/// Project files that may mention a symbol.
#[derive(Clone, Debug)]
@@ -105,7 +103,7 @@ impl LspManager {
}
/// Runs `query` on `t`; then again with the project files that mention the
/// symbol at `pos` outside `covered(first)` and the server's analysis scope opened.
/// symbol at `pos` and are not in `covered(first)` opened on the server.
/// A first answer that is empty while the server indexes is retried once
/// indexing ends.
pub(crate) async fn widened<R, F, Fut>(
@@ -148,10 +146,6 @@ impl LspManager {
}
let known: HashSet<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
let files: Vec<PathBuf> = candidates.files.into_iter().filter(|f| !known.contains(f)).collect();
let files = self.files_beyond_scope(t, files, cancel).await;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if files.is_empty() {
return Ok(Widened { first, second: None, incomplete });
}
@@ -180,27 +174,6 @@ impl LspManager {
}
}
async fn files_beyond_scope(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> Vec<PathBuf> {
if files.is_empty() || !t.caps().commands().iter().any(|c| c == COVERED_FILES) {
return files;
}
let uris: Vec<String> = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect();
let params = json!({ "command": COVERED_FILES, "arguments": [uris] });
// An unsupported, failed or malformed scope answer must not remove candidates.
let covered: IdeResult<Vec<String>> =
self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
match covered {
Ok(covered) => {
let covered: HashSet<_> = covered.into_iter().collect();
files.into_iter().zip(uris).filter_map(|(f, uri)| (!covered.contains(&uri)).then_some(f)).collect()
}
Err(e) => {
tracing::debug!("{}: analysis scope unavailable: {}", t.server.name, e.message);
files
}
}
}
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
/// also the declarations containing them, as incoming calls.
+55 -328
View File
@@ -52,8 +52,6 @@ pub enum Phase {
struct State {
generation: u64,
conn: Conn,
events: VecDeque<IdeEvent>,
publishing: bool,
}
/// A file opened on the server by the client itself (not by the user) for the
@@ -138,7 +136,7 @@ impl Server {
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle, events: VecDeque::new(), publishing: false }),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
initializing: Mutex::new(None),
start_cancel: Mutex::new(CancellationToken::new()),
phase: watch::channel(Phase::Down).0,
@@ -177,20 +175,8 @@ impl Server {
config
}
pub fn set_effective_config(&self, generation: u64, config: LanguageServerConfig) {
let st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_)) {
*self.effective.lock() = Some(config);
}
}
pub fn set_resolved_binary(&self, generation: u64, binary: Option<PathBuf>) -> bool {
let st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
}
*self.resolved_binary.write() = binary;
true
pub fn set_effective_config(&self, config: LanguageServerConfig) {
*self.effective.lock() = Some(config);
}
pub fn add_language(&self, language: &LanguageId) {
@@ -231,15 +217,15 @@ impl Server {
status: ServerStatus,
message: Option<String>,
) -> bool {
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
{
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
}
st.conn = conn;
}
st.conn = conn;
self.queue_status(&mut st, status, message);
self.phase.send_replace(Phase::Down);
drop(st);
self.publish(sink);
self.set_status(sink, status, message);
self.announce_down();
true
}
@@ -250,7 +236,8 @@ impl Server {
return false;
}
st.conn = Conn::Ready(run);
*self.initializing.lock() = None;
drop(st);
self.set_initializing(None);
true
}
@@ -262,6 +249,12 @@ impl Server {
}
}
/// Wakes requests waiting for a start that ended without an instance, after
/// its status is published.
pub fn announce_down(&self) {
self.phase.send_replace(Phase::Down);
}
pub fn running(&self) -> Option<Running> {
match &self.state.lock().conn {
Conn::Ready(r) => Some(r.clone()),
@@ -279,13 +272,8 @@ impl Server {
self.instance().filter(|r| r.generation == generation)
}
pub fn set_initializing(&self, run: Running) -> bool {
let st = self.state.lock();
if st.generation != run.generation || !matches!(st.conn, Conn::Starting) {
return false;
}
*self.initializing.lock() = Some(run);
true
pub fn set_initializing(&self, run: Option<Running>) {
*self.initializing.lock() = run;
}
/// The running instance if it is still generation `generation`.
@@ -302,7 +290,7 @@ impl Server {
/// Ready → Stopping, returning the instance to shut down. An in-flight
/// start is abandoned instead.
pub fn begin_stop(&self, sink: &SharedSink, final_stop: bool) -> Option<Running> {
pub fn begin_stop(&self) -> Option<Running> {
let mut st = self.state.lock();
match std::mem::replace(&mut st.conn, Conn::Idle) {
Conn::Ready(run) => {
@@ -313,20 +301,11 @@ impl Server {
Conn::Starting => {
st.generation += 1; // invalidates the in-flight start
self.start_cancel.lock().cancel();
self.queue_cleanup(&mut st);
self.queue_status(&mut st, ServerStatus::Stopped, None);
self.phase.send_replace(Phase::Down);
drop(st);
self.publish(sink);
None
}
other => {
st.conn = other;
if final_stop {
self.queue_status(&mut st, ServerStatus::Stopped, None);
}
drop(st);
self.publish(sink);
None
}
}
@@ -337,50 +316,28 @@ impl Server {
self.start_cancel.lock().clone()
}
pub fn end_stop(&self, generation: u64, sink: &SharedSink) {
pub fn end_stop(&self, generation: u64) {
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Stopping) {
return;
if st.generation == generation && matches!(st.conn, Conn::Stopping) {
st.conn = Conn::Idle;
}
self.queue_cleanup(&mut st);
self.queue_status(&mut st, ServerStatus::Stopped, None);
st.conn = Conn::Idle;
self.phase.send_replace(Phase::Down);
drop(st);
self.publish(sink);
}
/// Retires a crashed instance before another generation can start. Intentional
/// closes belong to `end_stop`; a late supervisor must not clear its successor.
pub fn on_closed(&self, generation: u64, sink: &SharedSink, code: Option<i32>) -> Option<Option<Duration>> {
/// Instance `generation`'s connection closed. `None` if superseded, else whether
/// the close was intentional. Waiters are woken by [`Self::announce_down`] once
/// the crash is reported, not here: they read the status to say why.
pub fn on_closed(&self, generation: u64) -> Option<bool> {
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Ready(_) | Conn::Starting) {
if st.generation != generation {
return None;
}
let tail = self.stderr_tail(5);
let cfg = self.effective_config();
let mut message = match (code, &cfg.connect) {
(Some(c), _) => format!("{} exited with code {c}", self.name),
(None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", self.name),
(None, None) => format!("{} terminated unexpectedly", self.name),
};
if !tail.is_empty() {
message.push_str(":\n");
message.push_str(&tail);
match &st.conn {
Conn::Ready(_) | Conn::Starting => {
st.conn = Conn::Idle;
Some(false)
}
_ => Some(true),
}
let delay = self.record_crash();
message.push_str(if delay.is_some() {
"\nRestarting…"
} else {
"\nCrashed repeatedly; restart it manually."
});
self.queue_cleanup(&mut st);
self.queue_status(&mut st, ServerStatus::Crashed, Some(message));
st.conn = if delay.is_some() { Conn::Idle } else { Conn::Failed };
self.phase.send_replace(Phase::Down);
drop(st);
self.publish(sink);
Some(delay)
}
pub fn is_idle_gen(&self, generation: u64) -> bool {
@@ -388,6 +345,13 @@ impl Server {
st.generation == generation && matches!(st.conn, Conn::Idle)
}
pub fn give_up(&self, generation: u64) {
let mut st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Idle) {
st.conn = Conn::Failed;
}
}
/// Records a crash; returns the backoff before restarting, or `None` once
/// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`]
/// (a server that crashes on every start, however slowly, is not restarted forever).
@@ -403,8 +367,8 @@ impl Server {
}
pub fn reset_for_restart(&self) {
let mut st = self.state.lock();
*self.crashes.lock() = 0;
let mut st = self.state.lock();
if matches!(st.conn, Conn::Missing | Conn::Failed) {
st.conn = Conn::Idle;
}
@@ -420,83 +384,24 @@ impl Server {
self.status.lock().1.clone()
}
fn queue_status(&self, st: &mut State, status: ServerStatus, message: Option<String>) {
let mut cur = self.status.lock();
if cur.0 == status && cur.1 == message {
return;
}
*cur = (status, message.clone());
st.events.push_back(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
}
fn queue_cleanup(&self, st: &mut State) {
*self.initializing.lock() = None;
for (id, title) in self.take_progress() {
st.events.push_back(IdeEvent::Task {
id,
title,
message: None,
fraction: None,
done: true,
cancellable: false,
});
}
self.progress_ended();
for path in self.take_diagnostic_paths() {
st.events.push_back(IdeEvent::Diagnostics {
path: path.into(),
source: self.name.clone(),
items: Vec::new(),
});
}
}
pub fn emit(&self, sink: &SharedSink, event: IdeEvent) {
self.state.lock().events.push_back(event);
self.publish(sink);
}
/// One drainer preserves event order, including when a sink reenters
/// the server. No state or manager lock is held across an external callback.
fn publish(&self, sink: &SharedSink) {
let mut st = self.state.lock();
if st.publishing {
return;
}
st.publishing = true;
loop {
let Some(event) = st.events.pop_front() else {
st.publishing = false;
pub fn set_status(&self, sink: &SharedSink, status: ServerStatus, message: Option<String>) {
{
let mut cur = self.status.lock();
if cur.0 == status && cur.1 == message {
return;
};
drop(st);
sink.emit(event);
st = self.state.lock();
}
*cur = (status, message.clone());
}
}
pub fn starting_status(&self, generation: u64, sink: &SharedSink) -> Option<CancellationToken> {
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return None;
}
self.queue_status(&mut st, ServerStatus::Starting, None);
let cancel = self.start_cancel.lock().clone();
drop(st);
self.publish(sink);
Some(cancel)
sink.emit(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
}
/// Ready ⇄ Busy depending on active `$/progress` tokens.
pub fn refresh_busy_status(&self, sink: &SharedSink) {
let mut st = self.state.lock();
if !matches!(st.conn, Conn::Ready(_)) {
if self.running().is_none() {
return;
}
let busy = self.is_busy();
self.queue_status(&mut st, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
drop(st);
self.publish(sink);
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
}
/// Whether the server reports work in progress (indexing, analysis).
@@ -617,11 +522,7 @@ impl Server {
// ---- bookkeeping ----
pub fn push_stderr(&self, generation: u64, line: String) {
let st = self.state.lock();
if st.generation != generation {
return;
}
pub fn push_stderr(&self, line: String) {
let mut buf = self.stderr.lock();
if buf.len() == STDERR_LINES {
buf.pop_front();
@@ -778,49 +679,6 @@ fn is_error_headline(line: &str) -> bool {
mod tests {
use super::*;
#[tokio::test]
async fn retirement_events_preserve_successor_diagnostics_on_the_same_path() {
for stopping in [false, true] {
let s = Arc::new(server());
let path = PathBuf::from("/p/a.vue");
let generation = s.begin_start().unwrap();
s.set_diagnostics(path.clone(), vec![lsp::Diagnostic::default()]);
s.progress.lock().insert(
"old".into(),
Progress { task: TaskId::next(), title: "Old work".into(), message: None, cancel: None },
);
let seen = Arc::new(Mutex::new(Vec::new()));
let manager = Arc::new(Mutex::new(std::sync::Weak::<crate::LspManager>::new()));
let sink: SharedSink = {
let (s, path, seen, manager) = (s.clone(), path.clone(), seen.clone(), manager.clone());
Arc::new(move |event: IdeEvent| match event {
IdeEvent::Diagnostics { items, .. } => seen.lock().push(items.len()),
IdeEvent::Task { done: true, .. } => {
s.begin_start().unwrap();
let shown = s.set_diagnostics(path.clone(), vec![lsp::Diagnostic::default()]);
let mgr = manager.lock().upgrade().unwrap();
mgr.emit_diagnostics(&s, path.clone(), shown, crate::text::Encoding::Utf16);
}
_ => {}
})
};
let mgr = crate::LspManager::new(
PathBuf::from("/p"),
Arc::new(ide_language::LanguageRegistry::from_configs(vec![])),
sink.clone(),
);
*manager.lock() = Arc::downgrade(&mgr);
if stopping {
s.state.lock().conn = Conn::Stopping;
s.end_stop(generation, &sink);
} else {
assert!(s.on_closed(generation, &sink, None).is_some());
}
assert_eq!(s.diagnostics_for(&path).len(), 1);
assert_eq!(*seen.lock(), vec![0, 1], "the UI must retain the replacement's diagnostics");
}
}
fn server() -> Server {
let cfg: LanguageServerConfig = toml::from_str("name = \"s\"\ncommand = \"s\"").unwrap();
Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue"))
@@ -887,143 +745,12 @@ mod tests {
fn a_superseded_start_is_not_announced() {
let s = server();
let generation = s.begin_start().unwrap();
s.begin_stop(); // abandons the start and invalidates its generation
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
s.begin_stop(&sink, false); // abandons the start and invalidates its generation
assert!(!s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
assert_eq!(s.status(), ServerStatus::Stopped);
}
#[test]
fn failure_publication_can_restart_without_overwriting_the_new_phase() {
for conn in [Conn::Missing, Conn::Failed] {
let s = Arc::new(server());
let generation = s.begin_start().unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
let sink: SharedSink = {
let (s, seen) = (s.clone(), seen.clone());
Arc::new(move |event: IdeEvent| {
if let IdeEvent::LanguageServer { status, .. } = event {
seen.lock().push(status);
if status == ServerStatus::Crashed {
s.reset_for_restart();
let next = s.begin_start().unwrap();
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
assert!(s.starting_status(next, &sink).is_some());
}
}
})
};
assert!(s.fail_start(generation, conn, &sink, ServerStatus::Crashed, Some("boom".into())));
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(s.status(), ServerStatus::Starting);
assert_eq!(*seen.lock(), vec![ServerStatus::Crashed, ServerStatus::Starting]);
}
}
#[test]
fn retirement_callbacks_cannot_clear_successor_state() {
for stopping in [false, true] {
let s = Arc::new(server());
let generation = s.begin_start().unwrap();
s.set_diagnostics(PathBuf::from("old"), vec![lsp::Diagnostic::default()]);
*s.loading.lock() = Some(true);
if stopping {
s.state.lock().conn = Conn::Stopping;
}
let sink: SharedSink = {
let s = s.clone();
Arc::new(move |event: IdeEvent| {
if matches!(event, IdeEvent::Diagnostics { .. }) {
let next = s.begin_start().unwrap();
s.set_diagnostics(PathBuf::from("new"), vec![lsp::Diagnostic::default()]);
*s.loading.lock() = Some(true);
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
assert!(s.starting_status(next, &sink).is_some());
}
})
};
if stopping {
s.end_stop(generation, &sink);
} else {
assert_eq!(s.on_closed(generation, &sink, None), Some(Some(Duration::from_millis(500))));
}
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(s.status(), ServerStatus::Starting);
assert!(s.diagnostics_for(Path::new("old")).is_empty());
assert_eq!(s.diagnostics_for(Path::new("new")).len(), 1);
assert!(s.is_loading());
assert_eq!(s.on_closed(generation, &sink, None), None);
s.end_stop(generation, &sink);
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(s.diagnostics_for(Path::new("new")).len(), 1);
}
}
#[test]
fn a_start_keeps_its_cancel_token_when_publication_restarts_it() {
let s = Arc::new(server());
let generation = s.begin_start().unwrap();
let sink: SharedSink = {
let s = s.clone();
Arc::new(move |event: IdeEvent| {
if matches!(event, IdeEvent::LanguageServer { status: ServerStatus::Starting, .. })
&& s.state.lock().generation == generation
{
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
s.begin_stop(&sink, false);
let next = s.begin_start().unwrap();
assert!(s.starting_status(next, &sink).is_some());
}
})
};
let cancel = s.starting_status(generation, &sink).unwrap();
assert!(cancel.is_cancelled());
assert!(!s.start_token().is_cancelled());
assert!(s.starting_status(generation, &sink).is_none());
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(s.status(), ServerStatus::Starting);
}
#[test]
fn abandoned_start_cannot_replace_successor_launch_metadata() {
let s = server();
let old = s.begin_start().unwrap();
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
s.begin_stop(&sink, false);
let next = s.begin_start().unwrap();
let mut config = s.config.clone();
config.command = "successor".into();
s.set_effective_config(next, config.clone());
assert!(s.set_resolved_binary(next, Some(PathBuf::from("successor"))));
s.push_stderr(next, "successor output".into());
config.command = "abandoned".into();
s.set_effective_config(old, config);
assert!(!s.set_resolved_binary(old, Some(PathBuf::from("abandoned"))));
s.push_stderr(old, "abandoned output".into());
assert_eq!(s.effective_config().command, "successor");
assert_eq!(*s.resolved_binary.read(), Some(PathBuf::from("successor")));
assert_eq!(s.stderr_tail(5), "successor output");
}
#[test]
fn intentional_close_is_retired_only_by_the_stop_owner() {
let s = server();
let generation = s.begin_start().unwrap();
s.state.lock().conn = Conn::Stopping;
s.set_diagnostics(PathBuf::from("old"), vec![lsp::Diagnostic::default()]);
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
assert_eq!(s.on_closed(generation, &sink, None), None);
assert_eq!(s.diagnostics_for(Path::new("old")).len(), 1);
assert!(s.begin_start().is_none());
s.end_stop(generation, &sink);
assert!(s.diagnostics_for(Path::new("old")).is_empty());
let next = s.begin_start().unwrap();
assert!(s.starting_status(next, &sink).is_some());
assert_eq!(s.on_closed(generation, &sink, None), None);
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(*s.crashes.lock(), 0);
}
#[test]
fn crash_excerpt_keeps_the_exception_line() {
// Node.js 24 crash of vue-language-server (issue #25), verbatim shape.
-6
View File
@@ -369,8 +369,6 @@ pub enum ReferencesIncomplete {
TooManyFiles,
/// Files that mention the name were too large or unreadable to check.
FilesSkipped,
/// A native query failed; indexed anchors are still available.
ServerFailed,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
@@ -391,10 +389,6 @@ pub struct References {
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Renamed {
/// Indexed names always require preview, including a single-file rename.
#[serde(default)]
#[specta(optional)]
pub preview: bool,
pub edit: WorkspaceEdit,
pub incomplete: Option<ReferencesIncomplete>,
}
-28
View File
@@ -1,28 +0,0 @@
package demo;
@lombok.Data
@lombok.Builder
class User {
private String name;
private int age;
}
@lombok.Getter
@lombok.Setter
@lombok.RequiredArgsConstructor
class Profile {
private final String id;
private String name;
}
@lombok.extern.java.Log
class Logged {
void write() { log.info("hello"); }
}
class PlainUser {
private String name;
PlainUser(String name) { this.name = name; }
String getName() { return name; }
void setName(String name) { this.name = name; }
}
+20 -540
View File
@@ -422,8 +422,7 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
// `a.mk` is open on the server: its changes reach it through document sync, not here.
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("changed.mk"), h.root.join("old.mk"));
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
// Other languages and files outside the server's root are left out.
let other = h.root.join("notes.txt");
let outside = tempfile::tempdir().unwrap().path().join("far.mk");
@@ -449,197 +448,6 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
h.mgr.shutdown_all().await;
}
/// An external change to a document open on the server's current instance reaches it only
/// through the reload's `didChange`: a watched-file change for it too would make a server
/// that re-reads the file on one (csharp-ls) apply the reload and then the diff computed
/// against the pre-reload copy, so it would keep analysing stale content (#1780). A closed
/// file's change is watched as before, and the file is watched again once its document closes.
#[tokio::test(flavor = "multi_thread")]
async fn documents_open_on_a_server_are_left_out_of_watched_file_changes() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
eventually(|| !s.notified("textDocument/didOpen").is_empty()).await;
// The open document and a closed file changed outside the editor.
let closed = h.root.join("b.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
// The editor reloads the open buffer from disk; the server hears the new text.
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "new")], &|| "new".to_owned());
eventually(|| s.docs.lock().get(uri_of(&path).as_str()).map(String::as_str) == Some("new")).await;
// Once the document closes, the file is watched again.
h.mgr.did_close(doc);
h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
async fn watch_all(s: &MockState) {
s.peer()
.request::<_, Value>(
"client/registerCapability",
json!({ "registrations": [{ "id": "all", "method": "workspace/didChangeWatchedFiles",
"registerOptions": { "watchers": [{ "globPattern": "**/*" }] } }] }),
None,
)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_keep_deletions_and_renames() {
for registered in [false, true] {
let h = harness(&[("mock", incremental_caps())]);
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
if registered {
watch_all(&s).await;
}
let closed = h.root.join("closed.mk");
// An atomic replacement is reported as created and still uses document sync.
h.mgr.watched_files_changed(std::slice::from_ref(&path), &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
let renamed = h.root.join("renamed.mk");
std::fs::rename(&path, &renamed).unwrap();
h.mgr.watched_files_changed(std::slice::from_ref(&renamed), &[], std::slice::from_ref(&path), &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([
{ "uri": uri_of(&renamed), "type": 1 },
{ "uri": uri_of(&path), "type": 3 }
])
);
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_resolve_path_aliases() {
for registered in [false, true] {
let h = harness(&[("mock", incremental_caps())]);
let (_, path) = h.open("Mixed.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
if registered {
watch_all(&s).await;
}
let mut aliases = vec![path.clone()];
let case_alias = h.root.join("mixed.mk");
if dunce::canonicalize(&case_alias).ok() == Some(path.clone()) {
aliases.push(case_alias);
}
#[cfg(unix)]
{
let link = h.root.join("alias.mk");
std::os::unix::fs::symlink(&path, &link).unwrap();
aliases.push(link);
}
let closed = h.root.join("closed.mk");
aliases.push(closed.clone());
h.mgr.watched_files_changed(&[], &aliases, &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_are_scoped_to_the_attached_server() {
let h = harness(&[("mock", incremental_caps()), ("other", incremental_caps())]);
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "mock").cloned().collect()));
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "other").cloned().collect()));
h.open("b.mk", "other");
h.wait_status("other", ServerStatus::Ready).await;
let first = h.launcher.state("mock");
let second = h.launcher.state("other");
watch_all(&first).await;
watch_all(&second).await;
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| {
!first.notified("workspace/didChangeWatchedFiles").is_empty()
&& !second.notified("workspace/didChangeWatchedFiles").is_empty()
})
.await;
assert_eq!(
first.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
assert_eq!(
second.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([
{ "uri": uri_of(&path), "type": 2 }, { "uri": uri_of(&closed), "type": 2 }
])
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_resume_on_a_restart_without_open_documents() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.did_close(doc);
h.mgr.restart(id).await.unwrap();
h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn servers_without_document_sync_keep_open_document_watches() {
let h = harness(&[("mock", json!({ "textDocumentSync": 0 }))]);
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }, { "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
/// A server that registers file watchers hears of what they match, inside excluded folders
/// too (`uv add` into `.venv`, `go get` rewriting `go.mod`); folders outside the project
/// that relative patterns name are reported for watching; unregistered, it falls back to
@@ -2083,10 +1891,11 @@ async fn java_implementations_come_from_sources_first() {
.collect::<Value>()
};
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
// Lines 0 and 1 are declared in the sources; 2 and 3 in a dependency, which implements 2 on line 3.
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
Some(3) => json!({ "locations": [], "complete": false }),
_ => json!({ "locations": [], "complete": true }),
}),
"textDocument/implementation" => Some(at(&[3, 1])),
@@ -2117,7 +1926,14 @@ async fn java_implementations_come_from_sources_first() {
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources: the standard request, with the dependencies.
// Declared in the sources and implemented nowhere there: no search through the dependencies.
let found = h.mgr.goto(doc, Pos::new(1, 6), GotoKind::Implementation).await.unwrap();
assert!(found.is_empty());
let markers = h.mgr.implementations(doc, &[Pos::new(1, 6)], Default::default()).await.unwrap();
assert!(markers[0].is_empty());
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources of a dependency's type: the standard request, with the dependencies.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
@@ -3122,112 +2938,6 @@ fn scope_harness() -> (Harness, Arc<MockState>) {
(h, s)
}
#[tokio::test(flavor = "multi_thread")]
async fn indexed_sources_are_not_widened_but_outside_callers_are() {
for classified in [false, true] {
for outside in [false, true] {
let mut caps = incremental_caps();
caps["executeCommandProvider"]["commands"] = if classified {
json!(["openidea.workspace.coveredFiles", "openidea.java.usages"])
} else {
json!(["openidea.workspace.coveredFiles"])
};
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let caller = h.root.join("Caller.java");
let decoy = h.root.join("Unrelated.java");
let extra = h.root.join("Outside.java");
std::fs::write(&caller, "target();").unwrap();
std::fs::write(&decoy, "target").unwrap();
if outside {
std::fs::write(&extra, "target();").unwrap();
}
let covered = vec![uri_of(&caller), uri_of(&decoy)];
let indexed = uri_of(&caller);
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if method == "workspace/executeCommand" && params["command"] == "openidea.workspace.coveredFiles" {
return Some(json!(covered));
}
let model = MockState::default();
*model.docs.lock() = s.docs.lock().clone();
model.docs.lock().insert(indexed.clone(), "target();".into());
let responder = open_files_only(vec![]);
if method == "workspace/executeCommand" && params["command"] == "openidea.java.usages" {
let refs = responder(&model, "textDocument/references", params).unwrap();
return Some(Value::Array(
refs.as_array()
.unwrap()
.iter()
.map(|location| json!({ "location": location, "kind": "unknown", "access": "read" }))
.collect(),
));
}
responder(&model, method, params)
}));
grep_candidates(&h);
let (doc, _) = h.open("Target.java", "def target\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let refs = h
.mgr
.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), None)
.await
.unwrap();
assert!(refs.locations.iter().any(|l| l.path == caller));
assert_eq!(refs.locations.iter().any(|l| l.path == extra), outside);
assert_eq!(refs.incomplete, None);
let calls = h
.mgr
.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default())
.await
.unwrap();
assert!(calls.items.iter().any(|i| i.path == caller));
assert_eq!(calls.items.iter().any(|i| i.path == extra), outside);
assert_eq!(calls.incomplete, None);
let rounds = if outside { 2 } else { 1 };
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), rounds);
if classified {
assert_eq!(
s.requested("workspace/executeCommand")
.iter()
.filter(|p| p["command"] == "openidea.java.usages")
.count(),
rounds
);
assert_eq!(refs.classified.len(), refs.locations.len());
} else {
assert_eq!(s.requested("textDocument/references").len(), rounds);
}
eventually(|| s.docs.lock().len() == 1).await;
let opened = uris(&s.notified("textDocument/didOpen"));
assert!(!opened.contains(&uri_of(&caller)));
assert!(!opened.contains(&uri_of(&decoy)));
assert_eq!(opened.contains(&uri_of(&extra)), outside);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() {
for response in [json!(null), json!([false]), json!([])] {
let mut caps = incremental_caps();
caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]);
let h = harness(&[("mock", caps)]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(open_files_only(vec![]));
s.respond("workspace/executeCommand", response);
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
files: vec![q.root.join("b.mk")],
limit_hit: true,
}));
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles));
assert_eq!(s.requested("textDocument/references").len(), 2);
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), 2);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn references_open_unopened_files_that_mention_the_name() {
let (h, s) = scope_harness();
@@ -3612,9 +3322,17 @@ async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
let scopes: Vec<_> =
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
// Scoped probes (code lenses) search everything at once.
// Scoped probes with the libraries search everything at once.
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 2);
// Usage counts and Safe Delete (no libraries): the sources alone, not widened.
let scoped =
h.mgr.references_streaming(doc, Pos::new(0, 5), true, false, false, Default::default(), None).await.unwrap();
assert_eq!(scoped.locations.len(), 1);
assert!(scoped.locations.iter().all(|l| l.uri.is_none()));
let scopes: Vec<_> =
s.requested("workspace/executeCommand")[5..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [None, Some(json!("sources"))]);
}
#[tokio::test(flavor = "multi_thread")]
@@ -4426,52 +4144,6 @@ async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_
assert_eq!(refs.incomplete, None);
}
/// The configuration index is rebuilt after Java changes, which can take seconds: Show Usages
/// shows the Java usages meanwhile, then adds the configuration ones.
#[tokio::test(flavor = "multi_thread")]
async fn java_usages_arrive_before_configuration_usages() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages", "openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let yaml = h.root.join("application.yml");
std::fs::write(&yaml, "app:\n target: 1\n").unwrap();
let property = json!({"uri": uri_of(&yaml), "range": {
"start": {"line": 1, "character": 2}, "end": {"line": 1, "character": 8}}});
let gate = Arc::new(Gate::default());
let held = gate.clone();
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
(method == "workspace/executeCommand").then(|| {
if params["command"] == "openidea.java.configuration" {
held.hold();
return json!({"locations": [property.clone()], "keys": [{"name": "app.target", "relaxed": true}], "roots": []});
}
let docs = s.docs.lock();
let rows = docs.iter().flat_map(|(uri, text)| occurrences(uri, text));
Value::Array(rows.map(|location| json!({"location": location, "kind": "unknown"})).collect())
})
}));
let (doc, a) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let partials = Mutex::new(Vec::<References>::new());
let push = |r| partials.lock().push(r);
let search = h.mgr.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), Some(&push));
let release = async {
eventually(|| !partials.lock().is_empty()).await;
assert!(gate.entered(), "shown before the configuration lookup started");
gate.open();
};
let (refs, ()) = tokio::join!(search, release);
let refs = refs.unwrap();
let partials = partials.into_inner();
assert_eq!(partials.len(), 1);
let paths = |r: &References| r.locations.iter().map(|l| l.path.to_path_buf()).collect::<Vec<_>>();
assert_eq!(paths(&partials[0]), std::slice::from_ref(&a));
assert_eq!(partials[0].classified.len(), 1);
assert_eq!(paths(&refs), [a, yaml]);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_extension_failure_preserves_native_java_navigation() {
let mut caps = incremental_caps();
@@ -4493,198 +4165,6 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
assert_eq!(s.requested("textDocument/definition").len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_lands_in_spring_xml_and_properties() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let s = h.launcher.state("jdtls");
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let resources = h.root.join("src/main/resources");
std::fs::create_dir_all(&resources).unwrap();
std::fs::write(
resources.join("context.xml"),
r#"<beans><util:properties id="db"><prop key="db.user">sa</prop></util:properties></beans>"#,
)
.unwrap();
std::fs::write(h.root.join("db.properties"), "db.user=root\n").unwrap();
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// The Java extension is not needed: the declarations come from the XML
// wiring and the properties file, and the open YAML never matches itself.
assert_eq!(locations.len(), 2, "{locations:?}");
assert!(locations.iter().any(|l| l.path.to_path_buf() == resources.join("context.xml")));
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("db.properties")));
assert!(s.requested("workspace/executeCommand").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholders_use_java_module_roots_and_utf16_columns() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(
h.root.join("pom.xml"),
"<project><packaging>pom</packaging><modules><module>child</module></modules></project>",
)
.unwrap();
let child = h.root.join("child");
let resources = child.join("src/main/resources");
std::fs::create_dir_all(&resources).unwrap();
std::fs::write(child.join("pom.xml"), "<project/>").unwrap();
let definition = resources.join("application.properties");
std::fs::write(&definition, "db.user=sa\n").unwrap();
let sibling = h.root.join("sibling");
std::fs::create_dir(&sibling).unwrap();
std::fs::write(sibling.join("pom.xml"), "<project/>").unwrap();
std::fs::write(sibling.join("application.properties"), "db.user=wrong\n").unwrap();
h.launcher.state("jdtls").respond("workspace/executeCommand", json!({
"locations": [], "keys": [], "roots": [uri_of(&h.root), uri_of(&child), uri_of(&child.join("src/main/java"))]
}));
for (file, language, text) in [
("references.properties", "properties", "message=你好😀 ${db.user}"),
("references.yml", "yaml", "message: 你好😀 ${db.user}"),
("context.xml", "xml", "<bean value=\"你好😀 ${db.user}\"/>"),
] {
let doc = DocId::next();
h.mgr.did_open(doc, &h.root.join(file), &LanguageId::new(language), 1, text);
let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32;
let locations = h.mgr.goto(doc, Pos::new(0, col + 2), GotoKind::Definition).await.unwrap();
assert_eq!(locations.len(), 1, "{file}: {locations:?}");
assert_eq!(locations[0].path.to_path_buf(), definition);
assert_eq!(locations[0].range.start, Pos::new(0, 0));
assert_eq!(locations[0].range.end, Pos::new(0, 7));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_file_list_refreshes_on_watched_changes_and_renames() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let child = h.root.join("child");
std::fs::create_dir(&child).unwrap();
std::fs::write(child.join("pom.xml"), "<project/>").unwrap();
let file = child.join("application.properties");
std::fs::write(&file, "db.user=sa\n").unwrap();
let doc = DocId::next();
h.mgr.did_open(doc, &child.join("reference.yml"), &LanguageId::new("yaml"), 1, "message: ${db.user}");
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
let profile = child.join("application-dev.properties");
std::fs::write(&profile, "db.user=dev\n").unwrap();
// No notification yet: the existing file list is reused.
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
h.mgr.watched_files_changed(std::slice::from_ref(&profile), &[], &[], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2);
std::fs::write(&profile, "db.user=development\n").unwrap();
h.mgr.watched_files_changed(&[], std::slice::from_ref(&profile), &[], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2);
let renamed = child.join("application-test.properties");
std::fs::rename(&profile, &renamed).unwrap();
h.mgr.did_rename_files(&[(profile, renamed.clone())]);
let locations = h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap();
assert_eq!(locations.len(), 2);
assert!(locations.iter().any(|l| l.path.to_path_buf() == renamed));
std::fs::remove_file(&renamed).unwrap();
h.mgr.watched_files_changed(&[], &[], &[renamed], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_merges_java_members_with_xml_declarations() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Db.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}],
"keys": [{"name": "db.user", "relaxed": true}], "roots": []
}),
);
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// A bound Java member and the XML declaration are both candidates.
assert_eq!(locations.len(), 2, "{locations:?}");
assert!(locations.iter().any(|l| l.range.start == Pos::new(1, 4)));
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml")));
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_survives_a_failing_java_extension() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).unwrap();
let s = h.launcher.state("jdtls");
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into()));
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// The XML declaration still answers when the Java extension fails.
assert_eq!(locations.len(), 1, "{locations:?}");
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml")));
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_key_definition_stays_java_only() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="spring.datasource.username">sa</prop></beans>"#)
.unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Settings.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}],
"keys": [{"name": "spring.datasource.username", "relaxed": true}], "roots": []
}),
);
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: sa\n");
let locations = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Definition).await.unwrap();
// A key keeps the Java-only definition path; the XML declaration is not a candidate.
assert_eq!(locations.len(), 1, "{locations:?}");
assert_eq!(locations[0].range.start, Pos::new(2, 4));
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "spring.datasource.username");
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_definition_on_an_xml_property_asks_java() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Db.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}],
"keys": [{"name": "db.user", "relaxed": true}], "roots": []
}),
);
let xml = h.root.join("context.xml");
let doc = DocId::next();
h.mgr.did_open(doc, &xml, &LanguageId::new("xml"), 1, "<beans><prop key=\"db.user\">sa</prop></beans>\n");
let locations = h.mgr.goto(doc, Pos::new(0, 19), GotoKind::Definition).await.unwrap();
// A Spring XML declaration is a configuration site: it navigates to the
// binding member through the Java index.
assert_eq!(locations.len(), 1, "{locations:?}");
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "db.user");
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
let mut caps = incremental_caps();
-140
View File
@@ -112,146 +112,6 @@ async fn kmp_lsp_java_end_to_end() {
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn java_searches_skip_indexed_sources_but_keep_exclusions_outside_scope() {
use ide_lsp::types::HierarchyDirection;
use serde_json::json;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
std::fs::create_dir_all(root.join("src/demo/excluded")).unwrap();
std::fs::write(root.join(".project"), "<projectDescription><name>search-scope</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>").unwrap();
std::fs::write(root.join(".classpath"), "<classpath><classpathentry kind=\"src\" path=\"src\" excluding=\"demo/excluded/**\"/><classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/><classpathentry kind=\"output\" path=\"bin\"/></classpath>").unwrap();
let source = "package demo; public class Target { public static void target() {} }";
let path = root.join("src/demo/Target.java");
let caller = root.join("src/demo/Caller.java");
let decoy = root.join("src/demo/Decoy.java");
let excluded = root.join("src/demo/excluded/Excluded.java");
let outside = root.join("Outside.java");
std::fs::write(&path, source).unwrap();
std::fs::write(&caller, "package demo; class Caller { void call() { Target.target(); } }").unwrap();
// A source file mentioning the name without a usage, too large for transient opening.
std::fs::write(&decoy, format!("package demo; class Decoy {{ void target() {{}} }}\n/*{}*/", " ".repeat(1 << 20)))
.unwrap();
std::fs::write(&excluded, "package demo.excluded; class Excluded {}").unwrap();
std::fs::write(&outside, "class Outside {}").unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
java.language_servers.retain(|s| s.name == "jdtls");
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend(["-data".into(), dir.path().join("metadata").to_string_lossy().into_owned()]);
let bundle = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/java-usages/openidea-java-usages.jar");
server
.initialization_options
.as_mut()
.unwrap()
.as_table_mut()
.unwrap()
.insert("bundles".into(), toml::Value::Array(vec![toml::Value::String(bundle.to_string_lossy().into_owned())]));
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
mgr.java_build(&path, &Default::default()).await.unwrap();
let uri = ide_lsp::path_to_uri_string;
let covered = retry("imported source scope", Duration::from_secs(120), || async {
mgr.jdtls_command(
&path,
"openidea.workspace.coveredFiles",
vec![json!([uri(&caller), uri(&decoy), uri(&excluded), uri(&outside),])],
)
.await
.ok()
.filter(|v| v.as_array().is_some_and(|a| !a.is_empty()))
})
.await;
assert_eq!(covered, json!([uri(&caller), uri(&decoy)]));
mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: vec![caller.clone(), decoy.clone()],
limit_hit: false,
}));
let at = Pos::new(0, source.find("target()").unwrap() as u32);
let refs = mgr.references_streaming(doc, at, false, true, false, Default::default(), None).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert!(refs.locations[0].path.ends_with("Caller.java"));
assert_eq!(refs.incomplete, None, "the indexed decoy must not be transiently opened");
let calls = mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, Default::default()).await.unwrap();
assert_eq!(calls.items.len(), 1);
assert!(calls.items[0].path.ends_with("Caller.java"));
assert_eq!(calls.incomplete, None);
mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn lombok_generated_members_appear_in_document_symbols() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let source = include_str!("fixtures/LombokSymbols.java");
let path = root.join("src/demo/LombokSymbols.java");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, source).unwrap();
let agent = dunce::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/lombok/lombok-1.18.48-openidea.3.jar"),
)
.unwrap();
std::fs::write(
root.join(".project"),
"<projectDescription><name>lombok-symbols</name><buildSpec><buildCommand>\
<name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures>\
<nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>",
)
.unwrap();
let library = agent.to_string_lossy().replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
std::fs::write(
root.join(".classpath"),
format!(
"<classpath><classpathentry kind=\"src\" path=\"src\"/>\
<classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/>\
<classpathentry kind=\"lib\" path=\"{library}\"/>\
<classpathentry kind=\"output\" path=\"bin\"/></classpath>"
),
)
.unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
java.language_servers.retain(|s| s.name == "jdtls");
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend([
"-data".into(),
root.join("workspace").to_string_lossy().into_owned(),
format!("--jvm-arg=-javaagent:{}", agent.display()),
]);
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
let expected: &[(&str, &[&str])] = &[
("User", &["getName()", "setName(String)", "getAge()", "setAge(int)", "User(String, int)", "builder()"]),
("UserBuilder", &["name(String)", "age(int)", "build()"]),
("Profile", &["Profile(String)", "getId()", "getName()", "setName(String)"]),
("Logged", &["log", "write()"]),
("PlainUser", &["PlainUser(String)", "getName()", "setName(String)"]),
];
fn has_members(items: &[ide_core::OutlineItem], owner: &str, names: &[&str]) -> bool {
items.iter().any(|item| {
(item.name == owner && names.iter().all(|n| item.children.iter().any(|c| c.name == *n)))
|| has_members(&item.children, owner, names)
})
}
retry("Lombok document symbols", Duration::from_secs(120), || async {
let items = mgr.document_symbols(doc).await.ok().flatten()?;
expected.iter().all(|(owner, names)| has_members(&items, owner, names)).then_some(())
})
.await;
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
+13 -81
View File
@@ -13,7 +13,6 @@
//! idle reclaim ends it.
use crate::runtime::{CallCancel, CallError, Runtime};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
@@ -21,17 +20,6 @@ use std::time::{Duration, Instant};
type Job = Box<dyn FnOnce(&mut Option<Runtime>) + Send>;
fn catch_call<R>(f: impl FnOnce() -> Result<R, CallError>) -> Result<R, CallError> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|panic| {
let message = panic
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("unknown panic");
Err(CallError::Trap(format!("plugin actor panicked: {message}")))
})
}
/// Why an [`Actor::call`] produced no result.
#[derive(Debug)]
pub enum ActorError {
@@ -97,24 +85,15 @@ impl Actor {
let cancel = runtime.cancel_handle();
let alive = Arc::new(AtomicBool::new(true));
let busy = Arc::new(AtomicBool::new(false));
let crash = Arc::new(Mutex::new(None));
let last_used = Arc::new(AtomicU64::new(0));
let epoch = Instant::now();
let thread = {
let (alive, busy, last_used, crash) = (alive.clone(), busy.clone(), last_used.clone(), crash.clone());
let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone());
std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || {
let mut slot = Some(runtime);
while let Ok(job) = rx.recv() {
busy.store(true, Ordering::Release);
// A completion callback may panic too; never leave a dead thread marked alive.
if let Err(CallError::Trap(error)) = catch_call(|| {
job(&mut slot);
Ok(())
}) {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(error);
alive.store(false, Ordering::Release);
slot = None;
}
job(&mut slot);
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
busy.store(false, Ordering::Release);
if slot.is_none() {
@@ -134,7 +113,7 @@ impl Actor {
thread: Mutex::new(Some(thread)),
cancel,
alive,
crash,
crash: Arc::default(),
busy,
last_used,
epoch,
@@ -169,7 +148,7 @@ impl Actor {
return;
}
let Some(rt) = slot.as_mut() else { return };
let outcome = catch_call(|| f(rt));
let outcome = f(rt);
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
// Before the reply: the caller sees the actor dead.
@@ -195,59 +174,21 @@ impl Actor {
cancel: &ide_core::CancellationToken,
grace: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, None, f)
}
/// A cancellable editor call: queued work expires after `soft`, and a
/// successful answer produced later is dropped. Running work still has
/// its runtime hard deadline and can be cancelled by further typing.
pub fn call_edit_cancellable<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
soft: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), f)
}
fn call_cancellable_before<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
deadline: Option<Instant>,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
let (reply, result) = mpsc::sync_channel(1);
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let skip = cancel.clone();
let running = Arc::new(Mutex::new(false));
let in_job = running.clone();
let job: Job = Box::new(move |slot| {
let Some(rt) = slot.as_mut() else { return };
{
let mut running = in_job.lock().unwrap_or_else(|e| e.into_inner());
if skip.is_cancelled() {
return;
}
if deadline.is_some_and(|t| Instant::now() >= t) {
let _ = reply.send(Err(ActorError::SoftTimeout));
return;
}
*running = true;
if skip.is_cancelled() {
return;
}
let outcome = catch_call(|| f(rt));
*in_job.lock().unwrap_or_else(|e| e.into_inner()) = false;
let Some(rt) = slot.as_mut() else { return };
let outcome = f(rt);
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
*slot = None;
}
let outcome = match outcome {
Ok(_) if deadline.is_some_and(|t| Instant::now() > t) => Err(ActorError::SoftTimeout),
other => other.map_err(ActorError::Call),
};
let _ = reply.send(outcome);
});
{
@@ -262,34 +203,25 @@ impl Actor {
drop(outcome);
return Err(ActorError::Cancelled);
}
Ok(outcome) => return outcome,
Ok(outcome) => return outcome.map_err(ActorError::Call),
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped });
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if !cancel.is_cancelled() {
if deadline.is_some_and(|t| Instant::now() >= t) && !*running.lock().unwrap_or_else(|e| e.into_inner())
{
return Err(ActorError::SoftTimeout);
}
continue;
}
match cancelled_at {
None => {
cancelled_at = Some(Instant::now());
let running = running.lock().unwrap_or_else(|e| e.into_inner());
if !*running {
return Err(ActorError::Cancelled);
if self.is_busy() {
self.cancel.cancel_soft();
}
self.cancel.cancel_soft();
}
Some(at) if at.elapsed() >= grace => {
// Did not return in time: interrupt it (the instance is rebuilt).
let running = running.lock().unwrap_or_else(|e| e.into_inner());
if *running {
self.cancel();
}
self.cancel();
return Err(ActorError::Cancelled);
}
Some(_) => {}
@@ -307,7 +239,7 @@ impl Actor {
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let job: Job = Box::new(move |slot| {
let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) };
let outcome = catch_call(|| f(rt));
let outcome = f(rt);
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
+1 -1
View File
@@ -20,6 +20,6 @@ pub use runtime::{
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
component_api, host_api,
};
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root};
pub use types::*;
pub use wasmtime::component::Component;
+39 -527
View File
@@ -4,27 +4,26 @@
//! themselves are in `wit_host`.
use crate::types::{
CodeAction, CommitDraft, CommitProblem, CompletionItem, FileOperation, InlineSuggestion, LineMarker, Location,
PluginHover, PluginInlayHint, ProviderDocument, SemanticToken,
CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
ProviderDocument, SemanticToken,
};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessChunk,
ProcessCommand, ProcessOutput, Prompt, RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken,
WorkbenchEvent,
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
};
use crate::types::{
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent,
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{
bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings,
indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
bindings, commit_checks_bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings,
services_bindings, syntax_bindings, views_bindings,
};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
@@ -36,15 +35,12 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
use details_bindings::exports::openidea::plugin::detail_provider as wit_detail;
use details_bindings::openidea::plugin::detail_types as wit_dt;
use endpoints_bindings::exports::openidea::plugin::endpoints_provider as wit_endpoints;
use file_actions_bindings::exports::openidea::plugin::file_actions as wit_fa;
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
use inlay_bindings::exports::openidea::plugin::inlay_hints_provider as wit_inlay;
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
use services_bindings::exports::openidea::plugin::service_provider as wit_svc;
use services_bindings::openidea::plugin::service_types as wit_st;
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
@@ -62,15 +58,7 @@ pub const CANCELLED: &str = "was cancelled";
/// which routes notifications and status items to windows and edits to open
/// documents. Capabilities are already checked when these are called.
pub trait HostApi: Send + Sync {
/// Shows a notification; `actions` name commands the plugin declares.
fn notify(
&self,
client: Option<&str>,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<crate::NotificationAction>,
) -> Result<(), HostError>;
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
@@ -79,10 +67,6 @@ pub trait HostApi: Send + Sync {
let _ = (client, url);
Ok(())
}
fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> {
let _ = (client, path, new_window);
Err(HostError::Invalid("opening projects is unavailable".into()))
}
/// Shows `prompt` in `client`'s window and waits for the answer until the
/// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the
/// text (`Input`, one element) or any value (`Confirm`: OK); `None` when
@@ -111,30 +95,6 @@ pub trait HostApi: Send + Sync {
}
/// Roots of the open projects.
fn projects(&self) -> Vec<std::path::PathBuf>;
/// The most specific open project containing a document (including a new file).
fn project_of(&self, path: &Path) -> Option<std::path::PathBuf> {
if !path.is_absolute() {
return None;
}
let real = crate::resolve_real(path);
self.projects()
.into_iter()
.filter(|root| real.starts_with(crate::resolve_real(root)))
.max_by_key(|root| root.components().count())
}
/// Searches the current in-memory index of an open project.
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<crate::SymbolInfo>, HostError> {
let _ = (root, query, limit, cancel, deadline);
Err(HostError::NotFound("no project index is available".into()))
}
/// Unsaved text of an open editor's file.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Version and text of the open document at `path`.
@@ -181,50 +141,17 @@ pub trait HostApi: Send + Sync {
/// Performs `request` (its host is granted), filling in secret placeholders.
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
fn secret_is_set(&self, name: &str) -> bool;
/// Runs the approved file of `process:<program>` with `cmd.env` added to
/// the inherited environment, and waits.
/// Runs the approved file of `process:<program>`.
fn run_process(
&self,
cmd: ProcessCommand,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
stdin: Option<String>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError>;
/// Delivers every event of `kind`, dropping its path filter.
fn subscribe(&self, kind: EventKind);
/// Delivers `kind` (`file-opened`, `file-saved`, `files-changed`) only for matching paths.
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError>;
fn unsubscribe(&self, kind: &str);
/// Starts `cmd` and returns the handle to it without waiting.
fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result<u64, HostError>;
/// What process `id` printed since the last call, each stream at most
/// `max` bytes (the rest waits for the next call), and its status once it
/// ended.
fn poll_process(&self, id: u64, max: usize) -> Result<ProcessChunk, HostError>;
/// Writes `text` to the stdin of process `id`.
fn write_process(
&self,
id: u64,
text: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError>;
/// Closes the stdin of process `id`.
fn close_process_stdin(&self, id: u64) -> Result<(), HostError>;
/// Ends process `id`; not an error if it already ended.
fn kill_process(&self, id: u64) -> Result<(), HostError>;
/// Forgets the handle and ends its process if it still runs.
fn release_process(&self, id: u64);
/// Waits up to `timeout` for process `id` to end (`ZERO` only checks) and
/// returns what it printed meanwhile with its status once it ended.
fn wait_process(
&self,
id: u64,
max: usize,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<ProcessChunk, HostError>;
fn subscribe(&self, kind: EventKind, on: bool);
/// Starts a progress indicator; cancelling it from the UI cancels `cancel`.
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64;
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>);
@@ -245,28 +172,8 @@ pub trait HostApi: Send + Sync {
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// Opens a log tab `key` in `client`'s window following `log` (already
/// checked against the plugin's grants and limits).
fn open_log_tab(&self, client: &str, key: String, title: String, log: ServiceLog) -> Result<(), HostError>;
/// The IDE's UI language as a BCP 47 tag.
fn locale(&self) -> String;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
/// Reads and decodes a text file outside the project, whose real path an
/// `fs.read` grant already covers: the unsaved buffer wins, then the disk.
/// `max` bounds the bytes read.
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError>;
/// The plugin's private directory, independent of the current invocation.
fn plugin_dir(&self) -> Result<PathBuf, HostError> {
Err(HostError::Failed("private plugin storage is unavailable".into()))
}
/// Replaces the text of a file whose real path the caller already checked:
/// in one step, keeping its encoding and BOM (a new file is UTF-8). Refused
/// while an editor holds unsaved changes for it.
fn write_text_file(&self, real: &Path, text: &str) -> Result<(), HostError>;
/// Replaces the bytes of a file whose real path the caller already checked,
/// in one step. Refused while an editor holds unsaved changes for it.
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError>;
}
/// Shared compilation engine plus an epoch ticker thread for wall-clock limits.
@@ -503,10 +410,8 @@ pub struct StoreData {
pub(crate) granted: Vec<Capability>,
pub(crate) host: Arc<dyn HostApi>,
pub(crate) invocation: Invocation,
/// The running call is a command or an event.
/// The running call is a command or an event, so it may ask the user.
pub(crate) interactive: bool,
/// Editor window for pick, pick-many, input and confirm during action resolution.
pub(crate) action_prompt_client: Option<String>,
/// Tasks begun by the running call and not ended yet.
pub(crate) open_tasks: Vec<u64>,
pub(crate) last_report: Option<Instant>,
@@ -635,15 +540,11 @@ struct Exports {
syntax: Option<wit_syntax::Guest>,
providers: Option<wit_prov::Guest>,
indexer: Option<wit_index::Guest>,
file_actions: Option<wit_fa::Guest>,
views: Option<wit_view::Guest>,
semantic: Option<wit_sem::Guest>,
details: Option<wit_detail::Guest>,
diagnostics: Option<crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider::Guest>,
services: Option<wit_svc::Guest>,
hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
inlay: Option<wit_inlay::Guest>,
endpoints: Option<wit_endpoints::Guest>,
}
/// Most results one syntax call may return (more are dropped).
@@ -655,10 +556,6 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
match e {
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
WorkbenchEvent::FilesChanged(batch) => wit_events::Event::Other(wit_types::Extension {
kind: "files-changed".into(),
data: serde_json::to_string(&batch).expect("file paths serialize"),
}),
WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p),
WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p),
WorkbenchEvent::Scheduled => wit_events::Event::Scheduled,
@@ -700,7 +597,6 @@ impl Runtime {
host: setup.host,
invocation: Invocation::default(),
interactive: false,
action_prompt_client: None,
open_tasks: Vec::new(),
last_report: None,
};
@@ -726,17 +622,12 @@ impl Runtime {
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
let index_indices = wit_index::GuestIndices::new(&pre).ok();
let file_action_indices = wit_fa::GuestIndices::new(&pre).ok();
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
let view_indices = wit_view::GuestIndices::new(&pre).ok();
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
let detail_indices = wit_detail::GuestIndices::new(&pre).ok();
use crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider as wit_diag;
let diagnostics_indices = wit_diag::GuestIndices::new(&pre).ok();
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices {
@@ -751,10 +642,6 @@ impl Runtime {
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let file_actions = match file_action_indices {
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let views = match view_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
@@ -763,14 +650,10 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let details = match detail_indices {
let services = match service_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let diagnostics = match diagnostics_indices {
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let hover = match hover_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
@@ -779,31 +662,9 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let inlay = match inlay_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let endpoints = match endpoint_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
Ok(Self {
store,
exports: Exports {
plugin,
syntax,
providers,
views,
indexer,
semantic,
details,
diagnostics,
hover,
commit_checks,
inlay,
endpoints,
file_actions,
},
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover, commit_checks },
limits: setup.limits,
version: setup.version,
})
@@ -985,23 +846,9 @@ impl Runtime {
selection: ide_core::Range,
timeout: Duration,
) -> Result<Vec<CodeAction>, CallError> {
let mut out = Vec::new();
if self.exports.providers.is_some() {
let d = doc_to_wit(doc);
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit));
}
if self.exports.file_actions.is_some() {
let d = file_action_doc(doc);
let actions = self.call(Invocation::default(), timeout, |e, store| {
e.file_actions.as_ref().expect("checked").call_code_actions(store, &d, range_to_wit(selection))
})?;
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(file_action_from_wit));
}
if out.is_empty() && self.exports.providers.is_none() {
return Err(CallError::missing_export("providers"));
}
Ok(out)
let d = doc_to_wit(doc);
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
}
/// The edits of an action `provide_code_actions` returned with `data`.
@@ -1009,30 +856,11 @@ impl Runtime {
&mut self,
doc: &ProviderDocument,
action: &CodeAction,
invocation: Invocation,
timeout: Duration,
) -> Result<CodeAction, CallError> {
if action.file_action {
if self.exports.file_actions.is_none() {
return Err(CallError::missing_export("file-actions"));
}
let d = file_action_doc(doc);
let a = file_action_to_wit(action);
let resolved = self.call(Invocation::default(), timeout, |e, store| {
store.data_mut().action_prompt_client = invocation.client;
e.file_actions.as_ref().expect("checked").call_resolve(store, &d, &a)
})?;
return resolved.map(file_action_from_wit).map_err(|e| CallError::Guest(e.into()));
}
let d = doc_to_wit(doc);
let a = code_action_to_wit(action);
if self.exports.providers.is_none() {
return Err(CallError::missing_export("providers"));
}
let resolved = self.call(Invocation::default(), timeout, |e, store| {
store.data_mut().action_prompt_client = invocation.client;
e.providers.as_ref().expect("checked").call_resolve_code_action(store, &d, &a)
})?;
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
}
@@ -1059,7 +887,7 @@ impl Runtime {
.take(MAX_PROVIDER_ITEMS)
.map(|m| LineMarker {
line: m.line,
icon: m.icon.map(|i| clip(i, 256)),
icon: m.icon.map(|i| clip(i, 32)),
tooltip: clip(m.tooltip, 1024),
target: m.target.map(location_from_wit),
})
@@ -1094,27 +922,6 @@ impl Runtime {
.collect())
}
pub fn provide_diagnostics(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<ide_core::Diagnostic>, CallError> {
use crate::wit_host::diagnostics_bindings::exports::openidea::plugin::diagnostics_provider as w;
if self.exports.diagnostics.is_none() {
return Err(CallError::missing_export("diagnostics-provider"));
}
let d = w::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let items = self.call(Invocation::default(), timeout, |e, store| {
e.diagnostics.as_ref().expect("checked").call_diagnostics(store, &d)
})?;
Ok(crate::wit_host::diagnostics_from_wit(items, &self.store.data().plugin_id))
}
pub fn provide_hover(
&mut self,
doc: &ProviderDocument,
@@ -1138,40 +945,6 @@ impl Runtime {
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
}
pub fn provide_inlay_hints(
&mut self,
doc: &ProviderDocument,
range: ide_core::Range,
timeout: Duration,
) -> Result<Vec<PluginInlayHint>, CallError> {
if self.exports.inlay.is_none() {
return Err(CallError::missing_export("inlay-hints-provider"));
}
let d = wit_inlay::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let r = wit_types::Range { start: pos_to_wit(range.start), end: pos_to_wit(range.end) };
let hints = self.call(Invocation::default(), timeout, |e, store| {
e.inlay.as_ref().expect("checked").call_inlay_hints(store, &d, r)
})?;
Ok(hints
.into_iter()
.filter(|h| !h.label.trim().is_empty())
.take(MAX_PROVIDER_ITEMS)
.map(|h| PluginInlayHint {
position: ide_core::Pos::new(h.position.line, h.position.character),
label: clip(h.label, 256),
kind: clip(h.kind, 32),
tooltip: h.tooltip.map(|t| clip(t, 1024)),
padding_left: h.padding_left,
padding_right: h.padding_right,
})
.collect())
}
pub fn activate(&mut self) -> Result<(), CallError> {
let data = self.store.data();
let ctx = wit_types::Context {
@@ -1237,37 +1010,6 @@ impl Runtime {
}
}
/// Project routes, with an explicit project invocation and background budget.
pub fn endpoints(&mut self, invocation: Invocation, timeout: Duration) -> Result<Vec<crate::Endpoint>, CallError> {
if self.exports.endpoints.is_none() {
return Err(CallError::missing_export("endpoints-provider"));
}
let result = self.call(invocation, timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let r = e
.endpoints
.as_ref()
.expect("checked")
.call_endpoints(&mut *store, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
let entries = result.map_err(|e| CallError::Guest(e.into()))?;
if entries.len() > MAX_PROVIDER_ITEMS {
return Err(CallError::Guest(GuestError::Invalid("too many endpoints".into())));
}
Ok(entries
.into_iter()
.map(|e| crate::Endpoint {
method: e.method,
path: e.path,
framework: e.framework,
handler: e.handler,
location: location_from_wit(e.location),
})
.collect())
}
/// The Services pane of node `key` of the plugin's services view `view`.
pub fn service_detail(
&mut self,
@@ -1275,13 +1017,13 @@ impl Runtime {
key: &str,
invocation: Invocation,
) -> Result<Vec<ServiceTab>, CallError> {
if self.exports.details.is_none() {
return Err(CallError::missing_export("detail-provider"));
if self.exports.services.is_none() {
return Err(CallError::missing_export("service-provider"));
}
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let details = e.details.as_ref().expect("checked");
let r = details.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
let services = e.services.as_ref().expect("checked");
let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
@@ -1375,7 +1117,6 @@ impl Runtime {
let data = self.store.data_mut();
data.invocation = Invocation::default();
data.interactive = false;
data.action_prompt_client = None;
// Tasks don't outlive the call that began them.
for id in std::mem::take(&mut data.open_tasks) {
data.host.task_end(id);
@@ -1486,10 +1227,7 @@ fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
.into_iter()
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
.collect(),
operations: Vec::new(),
open: None,
data: a.data.map(|d| clip(d, 64 << 10)),
file_action: false,
}
}
@@ -1513,82 +1251,6 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
}
}
const MAX_FILE_OPERATIONS: usize = 1000;
fn file_action_doc(doc: &ProviderDocument) -> wit_fa::Document {
wit_fa::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
}
}
fn file_action_from_wit(a: wit_fa::FileAction) -> CodeAction {
CodeAction {
title: clip(a.title, MAX_TITLE),
kind: a.kind.map(|k| clip(k, 64)),
preferred: a.preferred,
edits: a
.edits
.into_iter()
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
.collect(),
operations: a.operations.into_iter().take(MAX_FILE_OPERATIONS).map(operation_from_wit).collect(),
open: a.open.map(std::path::PathBuf::from),
data: a.data.map(|d| clip(d, 64 << 10)),
file_action: true,
}
}
fn file_action_to_wit(a: &CodeAction) -> wit_fa::FileAction {
wit_fa::FileAction {
title: a.title.clone(),
kind: a.kind.clone(),
preferred: a.preferred,
operations: a
.operations
.iter()
.map(|op| match op {
FileOperation::Create { path, text, overwrite } => wit_fa::FileOperation::Create(wit_fa::CreateFile {
path: to_ipc(path),
text: text.clone(),
overwrite: *overwrite,
}),
FileOperation::Rename { from, to } => {
wit_fa::FileOperation::Rename(wit_fa::RenameFile { path: to_ipc(from), new_path: to_ipc(to) })
}
FileOperation::Delete { path } => {
wit_fa::FileOperation::Delete(wit_fa::DeleteFile { path: to_ipc(path) })
}
})
.collect(),
edits: a
.edits
.iter()
.map(|(path, edits)| wit_fa::FileEdit {
path: to_ipc(path),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
.collect(),
})
.collect(),
open: a.open.as_deref().map(to_ipc),
data: a.data.clone(),
}
}
fn operation_from_wit(op: wit_fa::FileOperation) -> FileOperation {
match op {
wit_fa::FileOperation::Create(c) => {
FileOperation::Create { path: c.path.into(), text: c.text, overwrite: c.overwrite }
}
wit_fa::FileOperation::Rename(r) => FileOperation::Rename { from: r.path.into(), to: r.new_path.into() },
wit_fa::FileOperation::Delete(d) => FileOperation::Delete { path: d.path.into() },
}
}
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
s.map(|s| clip(s, max))
}
@@ -1608,11 +1270,11 @@ const MAX_LOG_TOGGLES: usize = 8;
const MAX_LOG_ARGS: usize = 256;
const MAX_LOG_ARG: usize = 4096;
fn service_tab_from_wit(t: wit_dt::Tab) -> ServiceTab {
fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
let body = match t.body {
wit_dt::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
wit_dt::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
wit_dt::Pane::Log(log) => {
wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
wit_st::Pane::Log(log) => {
let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG);
if args_fit(&log.args)
&& log.toggles.len() <= MAX_LOG_TOGGLES
@@ -1637,54 +1299,12 @@ fn service_tab_from_wit(t: wit_dt::Tab) -> ServiceTab {
ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body }
}
/// A `table` body carried by the `other` case (the sortable variant, plugins/
/// README.md §10): the usual table fields as JSON plus `sort`, the column the
/// plugin orders by and the direction, or null. Anything unparsable degrades
/// like a body this host doesn't know.
fn sortable_table_from_json(data: &str) -> Option<ViewTable> {
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Json {
#[serde(default)]
columns: Vec<String>,
#[serde(default)]
total_rows: u32,
#[serde(default)]
first_row: u32,
#[serde(default)]
rows: Vec<Vec<String>>,
#[serde(default)]
editable: bool,
#[serde(default)]
sort: Option<TableSort>,
}
let json: Json = serde_json::from_str(data).ok()?;
Some(ViewTable {
columns: json.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(),
total_rows: json.total_rows,
first_row: json.first_row,
rows: json
.rows
.into_iter()
.take(MAX_TABLE_ROWS as usize)
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
.collect(),
editable: json.editable,
sortable: true,
sort: json.sort,
})
}
fn view_from_wit(v: wit_vt::View) -> View {
let body = match v.body {
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
wit_vt::Body::Other(e) => match e.kind.as_str() {
"table" => match sortable_table_from_json(&e.data) {
Some(t) => ViewBody::Table(t),
None => ViewBody::Message("This view needs a newer version of OpenIDEA (table).".into()),
},
_ => ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64))),
},
wit_vt::Body::Other(e) => {
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
}
wit_vt::Body::Tree(items) => ViewBody::Tree(
items
.into_iter()
@@ -1725,23 +1345,8 @@ fn view_from_wit(v: wit_vt::View) -> View {
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
selected: clip(selected, MAX_TITLE),
},
wit_vt::FieldValue::Other(e) => match e.kind.as_str() {
"label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)),
"readonly-text" => {
#[derive(serde::Deserialize)]
struct ReadonlyText {
text: String,
#[serde(default)]
multiline: bool,
}
let value: ReadonlyText = serde_json::from_str(&e.data).ok()?;
FieldValue::ReadonlyText {
text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }),
multiline: value.multiline,
}
}
_ => return None,
},
// A field type of a newer API: left out.
wit_vt::FieldValue::Other(_) => return None,
};
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
})
@@ -1758,9 +1363,6 @@ fn view_from_wit(v: wit_vt::View) -> View {
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
.collect(),
editable: t.editable,
// The plain body predates sortable headers; `other` kind `table` opts in.
sortable: false,
sort: None,
}),
wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)),
};
@@ -1794,18 +1396,6 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
ViewEvent::InsertRow { before } => W::InsertRow(before),
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
ViewEvent::Message { text } => W::Message(text),
ViewEvent::TableSelect { row } => {
W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) })
}
ViewEvent::TableActivate { row } => {
W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) })
}
// A sortable table's header click, as an `other` view-event (plugins/
// README.md §10): the SDKs decode the kinds they know.
ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension {
kind: "sort".into(),
data: format!(r#"{{"column":{column},"descending":{descending}}}"#),
}),
}
}
@@ -1813,44 +1403,6 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
mod tests {
use super::*;
#[test]
fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() {
let fields = [
("label", r#""Title \"quoted\"""#),
("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#),
("readonly-text", r#"{"text":"author"}"#),
("label", "{}"),
("readonly-text", r#"{"text":false}"#),
("future", "null"),
]
.into_iter()
.enumerate()
.map(|(i, (kind, data))| wit_vt::Field {
key: i.to_string(),
label: "Field".into(),
value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }),
})
.collect();
let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None });
let ViewBody::Form(fields) = view.body else { panic!("form") };
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into()));
assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true });
assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false });
}
#[test]
fn table_row_events_leave_through_released_extension_case() {
for (event, kind) in [
(ViewEvent::TableSelect { row: 51 }, "table-select"),
(ViewEvent::TableActivate { row: 51 }, "table-activate"),
] {
let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") };
assert_eq!(extension.kind, kind);
assert_eq!(extension.data, r#"{"row":51}"#);
}
}
#[cfg(windows)]
#[test]
fn provider_and_resolved_action_paths_match_ipc_events() {
@@ -1866,10 +1418,7 @@ mod tests {
kind: None,
preferred: false,
edits: vec![(path.into(), Vec::new())],
operations: Vec::new(),
open: None,
data: None,
file_action: false,
};
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
}
@@ -1892,41 +1441,4 @@ mod tests {
let ViewBody::Form(fields) = form.body else { panic!("a form") };
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
}
#[test]
fn a_sortable_table_arrives_through_other() {
let view = |data: &str| wit_vt::View {
toolbar: Vec::new(),
body: wit_vt::Body::Other(wit_types::Extension { kind: "table".into(), data: data.into() }),
status: None,
};
let shown = view_from_wit(view(
r#"{"columns":["ext","files"],"totalRows":3,"firstRow":1,"rows":[["rs","2"],["md","1"]],
"editable":false,"sort":{"column":1,"descending":true}}"#,
));
let ViewBody::Table(t) = shown.body else { panic!("{shown:?}") };
assert_eq!(t.columns, ["ext".to_owned(), "files".into()]);
assert_eq!((t.total_rows, t.first_row, t.editable), (3, 1, false));
assert!(t.sortable, "a `table` through `other` opts the headers in");
assert_eq!(t.sort, Some(TableSort { column: 1, descending: true }));
// No sort yet: still clickable headers, just no arrow.
let fresh = view_from_wit(view(
r#"{"columns":["a"],"totalRows":0,"firstRow":0,"rows":[],"editable":false,"sort":null}"#,
));
let ViewBody::Table(t) = fresh.body else { panic!("{fresh:?}") };
assert!(t.sortable);
assert_eq!(t.sort, None);
// Broken JSON degrades like any body the host doesn't know.
let broken = view_from_wit(view("{not json"));
assert!(matches!(broken.body, ViewBody::Message(_)), "{broken:?}");
}
#[test]
fn a_sort_click_leaves_as_an_other_extension() {
let wit = event_to_wit_view(ViewEvent::Sort { column: 2, descending: true });
let wit_vt::ViewEvent::Other(e) = wit else { panic!("{wit:?}") };
assert_eq!((e.kind.as_str(), e.data.as_str()), ("sort", r#"{"column":2,"descending":true}"#));
}
}
Loaded 100 of 786 files, more files were not shown because too many files have changed in this diff. Show more