mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 03:08:18 -10:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81b7c1f36 | ||
|
|
6d37f0876c | ||
|
|
06a7aedb66 | ||
|
|
b746a0a3a9 | ||
|
|
2bf412ff0d | ||
|
|
ec171c3188 | ||
|
|
32a778d28d | ||
|
|
9673ccc687 | ||
|
|
fba893b36d | ||
|
|
905a672bad | ||
|
|
99102d8a40 | ||
|
|
a2599a6f79 | ||
|
|
bd1a3ce1e3 | ||
|
|
2329f1e955 | ||
|
|
63065bab64 | ||
|
|
7ecb452350 | ||
|
|
826e5837b1 | ||
|
|
04b1b863df | ||
|
|
a5db5cdda8 | ||
|
|
0630ed0729 | ||
|
|
b2d30e0c95 | ||
|
|
ff7473a277 | ||
|
|
45a1132ece | ||
|
|
704001fd8a | ||
|
|
9f77f46509 | ||
|
|
8a563bd185 | ||
|
|
d5af4d1554 | ||
|
|
1e78c4c877 | ||
|
|
34c8f87ba7 | ||
|
|
3ee5471ca2 | ||
|
|
ccb53af564 | ||
|
|
192b59801a | ||
|
|
6373bce476 | ||
|
|
1446d5592c | ||
|
|
fbf57bed23 | ||
|
|
292ddc4d2a | ||
|
|
af19880390 | ||
|
|
30a0521d0d | ||
|
|
4a78310f76 | ||
|
|
7cedf0b9be | ||
|
|
0bee9b7aab | ||
|
|
3efec1c848 | ||
|
|
e024c13db7 | ||
|
|
780e57d0c5 | ||
|
|
200ab10e65 | ||
|
|
dfcdd8d764 | ||
|
|
27e73ea2cb | ||
|
|
bb99c7c4d1 | ||
|
|
f6531542de | ||
|
|
9e6cb7b93e | ||
|
|
0b88599463 | ||
|
|
da3d996e10 | ||
|
|
4c6e535560 | ||
|
|
705f3557a8 | ||
|
|
4dcf7b0fb2 | ||
|
|
a6d24e684f | ||
|
|
1b889a5147 | ||
|
|
4d088c35ff | ||
|
|
60d7dcdca0 | ||
|
|
7479b7ad9d | ||
|
|
053a2c37d8 | ||
|
|
a40a909db5 | ||
|
|
c1e32b091a | ||
|
|
72b39d819f | ||
|
|
b790a9ddb6 | ||
|
|
f407880204 | ||
|
|
daad91777f | ||
|
|
c3671d782c | ||
|
|
a232850b88 | ||
|
|
0d939c774d | ||
|
|
3407195dc0 | ||
|
|
96eaced1cf | ||
|
|
3f65cb0f57 | ||
|
|
b43d9bf7fb | ||
|
|
fdb07e580c | ||
|
|
32ac603e87 | ||
|
|
377a8c8379 | ||
|
|
dc69c36604 | ||
|
|
1891119f9d | ||
|
|
8509ca671f | ||
|
|
ebc1c21836 | ||
|
|
429b8f0efe | ||
|
|
257fb0f50f | ||
|
|
668206ddfd | ||
|
|
4ef4adf9b3 | ||
|
|
d509ed6e81 | ||
|
|
112ed773fb | ||
|
|
f3837e0fe7 | ||
|
|
c720024b42 | ||
|
|
90cca59462 | ||
|
|
f96ca468a8 | ||
|
|
25d930b780 | ||
|
|
1ab4edc0d7 | ||
|
|
309bb48f2c | ||
|
|
75773dd889 | ||
|
|
1cab24fea1 | ||
|
|
bbbdcb465e | ||
|
|
e367f6757e | ||
|
|
619daa1489 | ||
|
|
2945d20208 | ||
|
|
e68b35f534 | ||
|
|
adc308eb99 | ||
|
|
e0408cc7d7 | ||
|
|
56796fb2c6 | ||
|
|
c614355753 | ||
|
|
7a9c72ca24 | ||
|
|
b23b95ec00 | ||
|
|
17a121e7f5 | ||
|
|
303d5950bf | ||
|
|
4cfdb972eb | ||
|
|
d235c432ec | ||
|
|
d3ea6f6daf |
No files matched your search
Generated
+7
-53
@@ -4319,6 +4319,7 @@ name = "ide-host"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"criterion",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4597,6 +4598,7 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"ide-build",
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
@@ -4675,7 +4677,6 @@ dependencies = [
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4782,12 +4783,14 @@ dependencies = [
|
||||
"ignore",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"trash",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -5425,10 +5428,7 @@ 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 +6603,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -6773,12 +6773,6 @@ 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"
|
||||
@@ -7236,15 +7230,6 @@ 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"
|
||||
@@ -7529,20 +7514,6 @@ 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"
|
||||
@@ -8360,7 +8331,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -8624,7 +8595,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"whoami 2.1.3",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10566,12 +10537,6 @@ 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"
|
||||
@@ -11146,17 +11111,6 @@ 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"
|
||||
|
||||
@@ -76,8 +76,4 @@ 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>
|
||||
+282
-40
@@ -109,6 +109,9 @@ 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>>,
|
||||
@@ -128,10 +131,7 @@ 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();
|
||||
// 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.
|
||||
// Model-specific options are offered separately, never as provider-wide defaults.
|
||||
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,14 +155,31 @@ impl Shared {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) {
|
||||
/// 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>,
|
||||
) {
|
||||
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.filter(|c| !c.is_empty() && known.config != *c) {
|
||||
known.config = c.to_vec();
|
||||
changed = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) =
|
||||
@@ -201,6 +218,9 @@ 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 {
|
||||
@@ -210,6 +230,8 @@ 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
|
||||
@@ -221,6 +243,7 @@ struct TaskState {
|
||||
/// A running agent and its session.
|
||||
#[derive(Clone)]
|
||||
struct Live {
|
||||
sign_in_message: String,
|
||||
peer: acp::Conn,
|
||||
session: String,
|
||||
capabilities: AgentCapabilities,
|
||||
@@ -339,6 +362,8 @@ 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
|
||||
})
|
||||
@@ -458,6 +483,18 @@ 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,
|
||||
@@ -491,6 +528,7 @@ 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;
|
||||
@@ -605,6 +643,8 @@ 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);
|
||||
@@ -612,10 +652,14 @@ 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) {
|
||||
@@ -624,8 +668,9 @@ 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);
|
||||
task.shared.remember(&provider, Some(&config), None, model.as_deref());
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
@@ -687,6 +732,7 @@ 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());
|
||||
@@ -705,12 +751,38 @@ 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
|
||||
@@ -871,6 +943,28 @@ 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)?;
|
||||
@@ -941,6 +1035,7 @@ 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(),
|
||||
}),
|
||||
@@ -1052,6 +1147,7 @@ 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));
|
||||
@@ -1301,11 +1397,7 @@ 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") => {
|
||||
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,
|
||||
})
|
||||
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
|
||||
}
|
||||
e => Failure::from(self.describe(e)),
|
||||
}
|
||||
@@ -1323,6 +1415,11 @@ 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);
|
||||
@@ -1368,7 +1465,7 @@ impl Task {
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
|
||||
format!("{} needs you to sign in. {}", spec.name, spec.login_hint)
|
||||
sign_in_message.clone()
|
||||
}
|
||||
e => self.describe(e),
|
||||
})?;
|
||||
@@ -1394,11 +1491,21 @@ 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();
|
||||
let (mut options, wanted) = {
|
||||
if known {
|
||||
self.shared.remember(&provider_id, Some(&reported), None, model_of(&reported).as_deref());
|
||||
}
|
||||
let (mut options, mut 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 };
|
||||
@@ -1408,6 +1515,9 @@ 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;
|
||||
@@ -1419,6 +1529,12 @@ 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());
|
||||
@@ -1427,7 +1543,8 @@ impl Task {
|
||||
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
|
||||
}
|
||||
}
|
||||
self.shared.remember(&provider_id, Some(&options), None);
|
||||
let model = model_of(&options).filter(|_| model_options);
|
||||
self.shared.remember(&provider_id, Some(&options), None, model.as_deref());
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
s.task.model = model_of(&options).or(s.task.model.take());
|
||||
@@ -1440,7 +1557,7 @@ impl Task {
|
||||
}
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
|
||||
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
|
||||
*self.live.lock() = Some(live.clone());
|
||||
Ok(live)
|
||||
}
|
||||
@@ -1476,11 +1593,19 @@ 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),
|
||||
}
|
||||
@@ -1540,12 +1665,18 @@ impl Task {
|
||||
return;
|
||||
}
|
||||
if kind == "session_info_update" {
|
||||
// The agent's own title for the session replaces the first line of the prompt.
|
||||
// The agent's own title for the session replaces the first line of the prompt,
|
||||
// unless the user named the task.
|
||||
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
|
||||
if let Some(title) = title {
|
||||
self.state.lock().task.title = title.to_owned();
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1579,11 +1710,15 @@ impl Task {
|
||||
}
|
||||
};
|
||||
if task_changed {
|
||||
let (config, commands) = {
|
||||
let (config, commands, model) = {
|
||||
let s = self.state.lock();
|
||||
(s.task.config.clone(), s.task.commands.clone())
|
||||
(
|
||||
s.task.config.clone(),
|
||||
s.task.commands.clone(),
|
||||
s.task.model.clone().filter(|_| kind == "config_option_update"),
|
||||
)
|
||||
};
|
||||
self.shared.remember(&provider, Some(&config), Some(&commands));
|
||||
self.shared.remember(&provider, Some(&config), Some(&commands), model.as_deref());
|
||||
if kind != "usage_update" {
|
||||
self.persist();
|
||||
}
|
||||
@@ -1620,12 +1755,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 title = params
|
||||
.pointer("/toolCall/title")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or("The agent asks for permission")
|
||||
.to_owned();
|
||||
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 };
|
||||
// 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)) {
|
||||
@@ -1647,11 +1782,10 @@ impl Task {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
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 asked: Vec<String> = questions
|
||||
.iter()
|
||||
.map(|question| question.answer_key.clone().unwrap_or_else(|| 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 });
|
||||
@@ -1664,7 +1798,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))
|
||||
@@ -1690,6 +1824,31 @@ 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" } }),
|
||||
@@ -1703,7 +1862,10 @@ impl Task {
|
||||
}
|
||||
let mut pending = self.permissions.lock();
|
||||
for entry in s.conversation.main.entries.iter().rev() {
|
||||
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
|
||||
// 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()
|
||||
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
|
||||
&& let Some(reply) = pending.remove(id)
|
||||
{
|
||||
@@ -1768,6 +1930,82 @@ 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 = ¶ms["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> {
|
||||
@@ -1883,6 +2121,10 @@ fn turn_starts(entries: &[AgentEntry]) -> Vec<usize> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn is_zero(n: &u32) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
@@ -282,6 +282,9 @@ 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,
|
||||
@@ -406,6 +409,9 @@ 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,
|
||||
|
||||
@@ -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(), cwd);
|
||||
let title = translate::command_approval_title(params, item.as_ref());
|
||||
let call = tool_call_of(
|
||||
&item_id,
|
||||
&title,
|
||||
@@ -871,31 +871,55 @@ impl CodexShim {
|
||||
Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" })))
|
||||
}
|
||||
"item/tool/requestUserInput" => {
|
||||
let mut answers = serde_json::Map::new();
|
||||
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 };
|
||||
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] }),
|
||||
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();
|
||||
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);
|
||||
.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 {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "answers": answers }))
|
||||
@@ -928,6 +952,25 @@ 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 };
|
||||
|
||||
@@ -247,13 +247,39 @@ 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 touch b"
|
||||
"Run ls"
|
||||
);
|
||||
}
|
||||
|
||||
#[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": [
|
||||
@@ -316,6 +342,39 @@ 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,
|
||||
|
||||
@@ -733,18 +733,19 @@ 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>, cwd: Option<&str>) -> String {
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
|
||||
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
|
||||
return format!("Allow network access to {host}");
|
||||
}
|
||||
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}"),
|
||||
// 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())),
|
||||
None => "Run a command".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,64 @@ 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`.
|
||||
@@ -119,13 +177,14 @@ 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.clone()),
|
||||
login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
|
||||
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(),
|
||||
}
|
||||
@@ -312,6 +371,81 @@ 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();
|
||||
|
||||
+562
-10
@@ -3,19 +3,55 @@
|
||||
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")
|
||||
@@ -87,6 +123,11 @@ 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" => {
|
||||
@@ -97,10 +138,10 @@ impl Handler for FakeAgent {
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
|
||||
}
|
||||
"session/new" => {
|
||||
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());
|
||||
if self.empty_config.load(Ordering::Acquire) {
|
||||
return Ok(json!({"sessionId": "s1"}));
|
||||
}
|
||||
let options = self.options_for("big");
|
||||
Ok(json!({"sessionId": "s1",
|
||||
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
|
||||
"configOptions": options}))
|
||||
@@ -121,6 +162,9 @@ 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" => {
|
||||
@@ -170,9 +214,37 @@ 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"}));
|
||||
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" }));
|
||||
}
|
||||
if text == "wait" {
|
||||
// Works until cancelled.
|
||||
@@ -250,6 +322,49 @@ 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();
|
||||
@@ -628,9 +743,10 @@ fn prompts_become_acp_content_blocks() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn answered_questions_close_the_question_with_every_picked_label() {
|
||||
async fn claude_questions_keep_the_answer_object_as_the_option_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
@@ -648,13 +764,22 @@ async fn answered_questions_close_the_question_with_every_picked_label() {
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, .. } => Some(*id),
|
||||
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)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
|
||||
mgr.respond(&task.id, question, Some(reply)).unwrap();
|
||||
mgr.respond(&task.id, question, Some(reply.clone())).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!(
|
||||
@@ -665,6 +790,115 @@ async fn answered_questions_close_the_question_with_every_picked_label() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -782,6 +1016,44 @@ 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(©.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();
|
||||
@@ -830,6 +1102,33 @@ 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")]
|
||||
@@ -863,6 +1162,22 @@ 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")]
|
||||
@@ -870,7 +1185,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 the New Task page shows the option without a choice.
|
||||
// the user picks another model, so provider-wide defaults never include it.
|
||||
*agent.extra_options.lock() = vec![json!({"id": "effort", "category": "thought_level",
|
||||
"type": "select", "currentValue": "high",
|
||||
"options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}]})];
|
||||
@@ -894,6 +1209,185 @@ 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 =
|
||||
@@ -963,6 +1457,64 @@ 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();
|
||||
|
||||
@@ -290,7 +290,8 @@ 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();
|
||||
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
|
||||
// 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, .. }));
|
||||
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() => {
|
||||
@@ -468,6 +469,27 @@ 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));
|
||||
|
||||
@@ -67,6 +67,11 @@ 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());
|
||||
@@ -153,7 +158,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"]) {
|
||||
} else if has(dir, &["go.mod", "go.work"]) {
|
||||
Some(BuildSystem::Go)
|
||||
} else if has(dir, &["tsconfig.json", "package.json"]) {
|
||||
Some(BuildSystem::TypeScript)
|
||||
@@ -227,7 +232,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)),
|
||||
};
|
||||
@@ -374,7 +379,33 @@ fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> V
|
||||
steps
|
||||
}
|
||||
|
||||
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
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> {
|
||||
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"];
|
||||
@@ -813,6 +844,79 @@ 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" };
|
||||
|
||||
@@ -166,6 +166,16 @@ 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.
|
||||
@@ -251,6 +261,7 @@ 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,
|
||||
@@ -268,6 +279,7 @@ 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(),
|
||||
@@ -464,3 +476,24 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ 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,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod editor_settings;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod go;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod inspection_settings;
|
||||
|
||||
@@ -299,9 +299,11 @@ 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` 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` — 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.
|
||||
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) =
|
||||
@@ -309,7 +311,12 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
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 mut fixes = Vec::new();
|
||||
for (key, value) in vars {
|
||||
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
|
||||
@@ -715,7 +722,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", "/tmp/.mount_OpenIDabcdef/jdk"),
|
||||
("JAVA_HOME", "/opt/jdk"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
@@ -728,6 +735,27 @@ 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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 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;
|
||||
@@ -35,10 +36,23 @@ 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 {
|
||||
@@ -66,6 +80,70 @@ pub fn discover_matching(
|
||||
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() {
|
||||
|
||||
@@ -850,6 +850,8 @@ 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.
|
||||
@@ -903,6 +905,8 @@ 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.
|
||||
@@ -953,6 +957,98 @@ 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 {
|
||||
@@ -969,9 +1065,28 @@ 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()
|
||||
@@ -1152,12 +1267,81 @@ 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();
|
||||
|
||||
+355
-84
@@ -12,14 +12,17 @@ use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use sqlx::{
|
||||
Column, ColumnIndex, Connection, Decode, Executor, MySql, Postgres, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
|
||||
mysql::{MySqlConnectOptions, MySqlPool, MySqlPoolOptions, MySqlSslMode},
|
||||
Column, ColumnIndex, Connection, Decode, Executor, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
|
||||
mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode},
|
||||
pool::PoolConnection,
|
||||
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
|
||||
postgres::{PgConnectOptions, PgConnection, PgSslMode},
|
||||
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -47,12 +50,135 @@ 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>);
|
||||
pub struct MssqlConnection(Option<MssqlClient>, Context);
|
||||
|
||||
impl std::ops::Deref for MssqlConnection {
|
||||
type Target = MssqlClient;
|
||||
@@ -76,7 +202,7 @@ impl ManageConnection for MssqlConnectionManager {
|
||||
|
||||
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
|
||||
tcp.set_nodelay(true)?;
|
||||
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
|
||||
let mut 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();
|
||||
@@ -88,7 +214,11 @@ impl ManageConnection for MssqlConnectionManager {
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
Ok(MssqlConnection(Some(client)))
|
||||
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)))
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
@@ -103,25 +233,13 @@ impl ManageConnection for MssqlConnectionManager {
|
||||
|
||||
pub enum Pool {
|
||||
Sqlite(SqlitePool),
|
||||
Postgres(PgPool),
|
||||
Mysql(MySqlPool),
|
||||
Postgres(ServerPool<PgManager>),
|
||||
Mysql(ServerPool<MysqlManager>),
|
||||
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) => {
|
||||
@@ -438,6 +556,19 @@ 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()
|
||||
@@ -535,7 +666,13 @@ 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") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
|
||||
(_, "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));
|
||||
}
|
||||
(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()),
|
||||
@@ -662,14 +799,12 @@ impl Pool {
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
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))
|
||||
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) }))
|
||||
}
|
||||
DbDriver::Mysql => {
|
||||
let mut opts = MySqlConnectOptions::new()
|
||||
@@ -711,14 +846,12 @@ impl Pool {
|
||||
if !tz.is_empty() {
|
||||
opts = opts.timezone(Some(tz.to_owned()));
|
||||
}
|
||||
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))
|
||||
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) }))
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
use tiberius::Config;
|
||||
@@ -757,7 +890,9 @@ impl Pool {
|
||||
match self {
|
||||
Pool::SqlServer(_) | Pool::Oracle(_) => {}
|
||||
Pool::Clickhouse(p) => p.close(),
|
||||
_ => with_pool!(self, p => p.close().await),
|
||||
Pool::Sqlite(p) => p.close().await,
|
||||
Pool::Postgres(p) => p.closed.store(true, Ordering::Relaxed),
|
||||
Pool::Mysql(p) => p.closed.store(true, Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,7 +901,9 @@ impl Pool {
|
||||
Pool::SqlServer(_) => false,
|
||||
Pool::Oracle(p) => p.is_closed(),
|
||||
Pool::Clickhouse(p) => p.is_closed(),
|
||||
_ => with_pool!(self, 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,18 +912,9 @@ impl Pool {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
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)),
|
||||
};
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
conn.apply_context(None).await?;
|
||||
let fetched = conn.fetch_rows(sql, max).await;
|
||||
Ok(fetched?.1)
|
||||
}
|
||||
|
||||
@@ -809,7 +937,10 @@ impl Pool {
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
|
||||
let r = match conn.apply_context(None).await {
|
||||
Ok(()) => conn.execute_from(self, sql, skip, max_rows, cancel).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
conn.discard().await;
|
||||
return Err(IdeError::cancelled());
|
||||
@@ -822,8 +953,10 @@ impl Pool {
|
||||
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
match self {
|
||||
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
Pool::SqlServer(_) => {
|
||||
let mut held = Held::acquire(self).await?;
|
||||
held.apply_context(None).await?;
|
||||
let Held::SqlServer(mut conn) = held else { unreachable!() };
|
||||
let result = async {
|
||||
mssql_command(&mut conn, "BEGIN TRAN").await?;
|
||||
let mut total = 0;
|
||||
@@ -846,8 +979,10 @@ impl Pool {
|
||||
}
|
||||
result
|
||||
}
|
||||
Pool::Oracle(p) => {
|
||||
let mut conn = p.get().await?;
|
||||
Pool::Oracle(_) => {
|
||||
let mut held = Held::acquire(self).await?;
|
||||
held.apply_context(None).await?;
|
||||
let Held::Oracle(mut conn) = held else { unreachable!() };
|
||||
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.
|
||||
@@ -855,21 +990,37 @@ impl Pool {
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => 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;
|
||||
_ => {
|
||||
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)
|
||||
}};
|
||||
}
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(total)
|
||||
}),
|
||||
match &mut conn {
|
||||
Held::Sqlite(c) => transaction!(c),
|
||||
Held::Postgres(c) => transaction!(&mut ***c),
|
||||
Held::Mysql(c) => transaction!(&mut ***c),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -878,8 +1029,8 @@ impl Pool {
|
||||
/// manual-commit console across statements with its transaction open.
|
||||
pub enum Held {
|
||||
Sqlite(PoolConnection<Sqlite>),
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
Postgres(Box<bb8::PooledConnection<'static, PgManager>>),
|
||||
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
@@ -889,8 +1040,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(p.acquire().await.map_err(db_err)?),
|
||||
Pool::Mysql(p) => Held::Mysql(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::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
|
||||
Pool::Clickhouse(p) => {
|
||||
if p.is_closed() {
|
||||
@@ -902,8 +1053,121 @@ 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()),
|
||||
@@ -918,8 +1182,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)
|
||||
@@ -932,11 +1196,15 @@ impl Held {
|
||||
pub async fn discard(self) {
|
||||
match self {
|
||||
Held::Sqlite(c) => drop(c.detach()),
|
||||
Held::Postgres(c) => {
|
||||
let _ = c.detach().close().await;
|
||||
Held::Postgres(mut c) => {
|
||||
if let Some(raw) = c.conn.take() {
|
||||
let _ = raw.close().await;
|
||||
}
|
||||
}
|
||||
Held::Mysql(c) => {
|
||||
let _ = c.detach().close().await;
|
||||
Held::Mysql(mut c) => {
|
||||
if let Some(raw) = c.conn.take() {
|
||||
let _ = raw.close().await;
|
||||
}
|
||||
}
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
@@ -971,6 +1239,7 @@ 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 {
|
||||
@@ -1003,9 +1272,9 @@ impl Held {
|
||||
let fetched: Fetched = match self {
|
||||
Held::Sqlite(c) => run!(c, None::<&str>, |_: String| String::new()),
|
||||
Held::Postgres(c) => {
|
||||
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
|
||||
run!(&mut ***c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
|
||||
}
|
||||
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
|
||||
Held::Mysql(c) => run!(&mut ***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! {
|
||||
@@ -1016,6 +1285,7 @@ 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 {
|
||||
@@ -1055,7 +1325,7 @@ mod tests {
|
||||
#[test]
|
||||
fn discarded_sqlserver_connections_are_broken() {
|
||||
let manager = MssqlConnectionManager(tiberius::Config::new());
|
||||
assert!(manager.has_broken(&mut MssqlConnection(None)));
|
||||
assert!(manager.has_broken(&mut MssqlConnection(None, Context::new(None))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1191,6 +1461,7 @@ 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());
|
||||
}
|
||||
|
||||
|
||||
+51
-11
@@ -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.command("ROLLBACK").await.is_err() || conn.restore_default().await.is_err())
|
||||
{
|
||||
conn.discard().await;
|
||||
}
|
||||
@@ -704,8 +704,15 @@ 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("only PostgreSQL data sources list databases"));
|
||||
return Err(IdeError::invalid("this data source does not 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();
|
||||
@@ -1038,11 +1045,13 @@ impl DbManager {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
self.execute_from(id, statement, 0, max_rows, cancel).await
|
||||
self.execute_from(id, statement, 0, max_rows, cancel, None).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,
|
||||
@@ -1050,8 +1059,9 @@ impl DbManager {
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
schema: Option<&str>,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let r = self.run(id, statement, skip, max_rows, cancel).await;
|
||||
let r = self.run(id, statement, skip, max_rows, cancel, schema).await;
|
||||
let ran = match &r {
|
||||
Ok(_) => true,
|
||||
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
|
||||
@@ -1083,8 +1093,15 @@ impl DbManager {
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// The execution plan of `statement` (explained, not run).
|
||||
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
|
||||
/// 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>> {
|
||||
let driver = self.pool(id).await?.driver();
|
||||
if driver == DbDriver::Oracle {
|
||||
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
|
||||
@@ -1102,14 +1119,16 @@ 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).await?;
|
||||
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel, None).await?;
|
||||
let nodes = plan::parse(driver, &result);
|
||||
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
|
||||
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel, None).await;
|
||||
return nodes;
|
||||
}
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
|
||||
let result =
|
||||
self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel, schema).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -1120,11 +1139,25 @@ 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 {
|
||||
return pool.execute_from(statement, skip, max_rows, cancel).await;
|
||||
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;
|
||||
};
|
||||
if pool.driver() == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
@@ -1132,10 +1165,12 @@ 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.
|
||||
@@ -1171,7 +1206,12 @@ impl DbManager {
|
||||
let Some(session) = session else { return Ok(()) };
|
||||
let mut held = session.lock().await;
|
||||
let Some(mut conn) = held.take() else { return Ok(()) };
|
||||
match conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
|
||||
let result = async {
|
||||
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
|
||||
conn.restore_default().await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
conn.discard().await;
|
||||
|
||||
@@ -181,7 +181,7 @@ impl OraclePool {
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))?
|
||||
.map_err(oracle_err)?;
|
||||
let _ = pool.add(OracleHandle { conn: Some(opened) });
|
||||
let _ = pool.add(opened);
|
||||
Ok(Self { state, pool: Arc::new(pool) })
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ 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>);
|
||||
@@ -222,7 +223,7 @@ impl ManageConnection for OracleConnectionManager {
|
||||
.await
|
||||
.map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?;
|
||||
let conn = opened.map_err(OracleError::new)?;
|
||||
Ok(OracleHandle { conn: Some(conn) })
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
@@ -271,6 +272,10 @@ 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()
|
||||
}
|
||||
@@ -282,7 +287,7 @@ impl OracleConnection {
|
||||
}
|
||||
}
|
||||
|
||||
fn open(state: &State) -> oracle::Result<oracle::Connection> {
|
||||
fn open(state: &State) -> oracle::Result<OracleHandle> {
|
||||
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 {
|
||||
@@ -293,7 +298,12 @@ fn open(state: &State) -> oracle::Result<oracle::Connection> {
|
||||
for sql in &state.init {
|
||||
statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?;
|
||||
}
|
||||
Ok(conn)
|
||||
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, ¤t));
|
||||
Ok(OracleHandle { conn: Some(conn), context })
|
||||
}
|
||||
|
||||
/// Runs `f` with the connection on the blocking pool. Cancelling drops the
|
||||
@@ -558,7 +568,7 @@ mod tests {
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
let mut broken = OracleHandle { conn: None };
|
||||
let mut broken = OracleHandle { conn: None, context: crate::conn::Context::new(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).
|
||||
|
||||
@@ -63,6 +63,11 @@ 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",
|
||||
@@ -396,6 +401,18 @@ 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")]
|
||||
@@ -994,6 +1011,20 @@ 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 = [
|
||||
|
||||
@@ -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).await?;
|
||||
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel, None).await?;
|
||||
check(!plan.is_empty(), "query plan missing")?;
|
||||
let value = r"path\O'Brien";
|
||||
let result = db
|
||||
|
||||
@@ -85,3 +85,101 @@ 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(())
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -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).await?;
|
||||
let second = db.execute_from("s1", sql, 1, 1, &c, None).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).await?;
|
||||
let last = db.execute_from("s1", sql, 2, 5, &c, None).await?;
|
||||
assert_eq!(ids(&last), vec!["3"]);
|
||||
assert!(!last.has_more);
|
||||
|
||||
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
|
||||
let past = db.execute_from("s1", sql, 9, 5, &c, None).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).await?;
|
||||
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c, None).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).await?;
|
||||
db.explain("s1", "DELETE FROM users", &c, None).await?;
|
||||
assert_eq!(count(&db).await, "3");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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_HOST`, else the context's endpoint).
|
||||
/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context).
|
||||
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
|
||||
let url = match ep {
|
||||
DockerEndpoint::Socket { path } => path.trim().to_owned(),
|
||||
@@ -451,17 +451,31 @@ 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 => {
|
||||
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::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::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}}"];
|
||||
@@ -506,6 +520,34 @@ 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 [
|
||||
|
||||
@@ -18,13 +18,15 @@ 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 (file opened, saved, focus…), not keystrokes.
|
||||
/// Workbench events (files, focus, VCS), not keystrokes.
|
||||
Events,
|
||||
/// Keystrokes and editor input events.
|
||||
InputEvents,
|
||||
@@ -36,6 +38,8 @@ 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).
|
||||
@@ -54,10 +58,14 @@ impl Capability {
|
||||
pub fn risk(&self) -> Risk {
|
||||
match self {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
|
||||
Risk::Medium
|
||||
}
|
||||
Self::Process(_) | Self::FsRead(_) => Risk::High,
|
||||
Self::EditorWrite
|
||||
| Self::ProjectWrite
|
||||
| Self::VcsWrite
|
||||
| Self::Net(_)
|
||||
| Self::Secrets
|
||||
| Self::InputEvents
|
||||
| Self::Clipboard => Risk::Medium,
|
||||
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,11 +81,12 @@ 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 workbench events (files opened, saved, focus)".into(),
|
||||
Self::Events => "Observe editor, filesystem and version control events".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()
|
||||
@@ -85,6 +94,7 @@ 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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +102,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::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
|
||||
std::path::Path::new(path).starts_with(std::path::Path::new(base))
|
||||
}
|
||||
_ => self == wanted,
|
||||
@@ -116,6 +126,7 @@ 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"),
|
||||
@@ -126,6 +137,7 @@ 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,11 +170,11 @@ impl FromStr for Capability {
|
||||
}
|
||||
Ok(Self::Process(arg.to_owned()))
|
||||
}
|
||||
"fs.read" => {
|
||||
"fs.read" | "fs.write" => {
|
||||
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
|
||||
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
|
||||
}
|
||||
Ok(Self::FsRead(arg.to_owned()))
|
||||
Ok(if kind == "fs.read" { Self::FsRead(arg.to_owned()) } else { Self::FsWrite(arg.to_owned()) })
|
||||
}
|
||||
_ => Err(format!("unknown capability {s:?}")),
|
||||
};
|
||||
@@ -171,6 +183,7 @@ 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,
|
||||
@@ -224,6 +237,7 @@ mod tests {
|
||||
for s in [
|
||||
"project.read",
|
||||
"editor.write",
|
||||
"project.write",
|
||||
"vcs.write",
|
||||
"storage",
|
||||
"secrets",
|
||||
@@ -236,12 +250,12 @@ mod tests {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
// Absolute paths are the platform's.
|
||||
let (git, etc) = if cfg!(windows) {
|
||||
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
|
||||
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")
|
||||
} else {
|
||||
("process:/usr/bin/git", "fs.read:/etc")
|
||||
("process:/usr/bin/git", "fs.read:/etc", "fs.write:/out")
|
||||
};
|
||||
for s in [git, etc] {
|
||||
for s in [git, etc, out] {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
|
||||
@@ -249,6 +263,7 @@ 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +308,13 @@ 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() {
|
||||
@@ -325,15 +332,21 @@ 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(dunce_canonical(&p));
|
||||
return Ok(canonical_dir(&p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!("{name} was not found on PATH"))
|
||||
}
|
||||
|
||||
fn dunce_canonical(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
/// `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 is_executable(p: &Path) -> bool {
|
||||
@@ -474,6 +487,42 @@ 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();
|
||||
|
||||
@@ -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, PreparedSet};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -144,7 +144,8 @@ 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", "editor-focused", "action-performed", "vcs-changed"];
|
||||
pub const EVENT_KINDS: &[&str] =
|
||||
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -315,6 +316,11 @@ 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());
|
||||
@@ -326,6 +332,9 @@ 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}"));
|
||||
}
|
||||
@@ -490,6 +499,10 @@ 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");
|
||||
@@ -583,6 +596,29 @@ 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();
|
||||
|
||||
@@ -9,6 +9,11 @@ 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")]
|
||||
@@ -127,6 +132,20 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ ide-vfs.workspace = true
|
||||
regex-syntax.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
ide-language.workspace = true
|
||||
blake3.workspace = true
|
||||
ide-index.workspace = true
|
||||
ide-syntax.workspace = true
|
||||
ide-search.workspace = true
|
||||
@@ -48,7 +49,7 @@ ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
|
||||
@@ -15,8 +15,22 @@ 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 | --version");
|
||||
eprintln!("usage: openidea-server --stdio | --connect <host> <port> | --version");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// stdout carries the protocol: logs go to stderr.
|
||||
@@ -78,6 +92,11 @@ 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 {
|
||||
|
||||
@@ -22,6 +22,10 @@ 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")]
|
||||
@@ -72,7 +76,7 @@ impl Environment {
|
||||
return None;
|
||||
}
|
||||
let config = self.load_project(root, sink);
|
||||
let post_start = config.and_then(|c| c.post_start);
|
||||
let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none());
|
||||
let first = self.state.lock().post_started.insert(root.to_path_buf());
|
||||
post_start.filter(|_| first)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ mod phpstan;
|
||||
pub mod plugins;
|
||||
mod project;
|
||||
mod proxy;
|
||||
pub mod relay;
|
||||
mod requests;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
@@ -223,6 +224,8 @@ 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);
|
||||
@@ -373,6 +376,10 @@ 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();
|
||||
}
|
||||
|
||||
@@ -257,6 +257,10 @@ 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
|
||||
@@ -309,6 +313,7 @@ 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",
|
||||
|
||||
@@ -17,14 +17,6 @@ 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 {
|
||||
@@ -40,7 +32,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 = java_env(jdk.as_ref(), p.java_version);
|
||||
p.env = ide_sdk::apply::maven_build_env(root, settings, p.java_version);
|
||||
if let Some(mvn) = &mvn {
|
||||
p.command = mvn.to_string_lossy().into_owned();
|
||||
}
|
||||
@@ -64,7 +56,11 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
if let Some(m) = &mut model.maven
|
||||
&& m.sync_error.is_some()
|
||||
{
|
||||
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
|
||||
let hint = if settings.languages.java.maven.runner_jre.trim().is_empty() {
|
||||
m.projects.iter().find_map(|p| missing(p.java_version))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
explain(&mut m.sync_error, hint);
|
||||
}
|
||||
for g in &mut model.gradle {
|
||||
@@ -492,15 +488,32 @@ 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>,
|
||||
@@ -519,7 +532,10 @@ pub async fn build_run(
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
|
||||
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 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();
|
||||
@@ -616,6 +632,7 @@ 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")?,
|
||||
@@ -625,9 +642,45 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
mod build_tool_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();
|
||||
@@ -710,6 +763,34 @@ mod gradle_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();
|
||||
|
||||
@@ -103,7 +103,8 @@ 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")?);
|
||||
explain(&c.host, &c.window, request, &id, &statement).await
|
||||
let schema = c.arg::<Option<String>>("schema")?;
|
||||
explain(&c.host, &c.window, request, &id, &statement, schema.as_deref()).await
|
||||
});
|
||||
d.add("db_history", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
@@ -130,7 +131,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
|
||||
c.arg::<u32>("maxRows")?,
|
||||
);
|
||||
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
|
||||
let schema = c.arg::<Option<String>>("schema")?;
|
||||
execute(&c.host, &c.window, request, &id, statements, skip, max_rows, schema.as_deref()).await
|
||||
});
|
||||
d.add("db_table_page", |c| async move {
|
||||
let (request, id, schema, table) = (
|
||||
@@ -289,10 +291,11 @@ 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).await;
|
||||
let result = db.explain(id, statement, &cancel, schema).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
@@ -327,7 +330,9 @@ 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`. Cancel with `cancel_request(request)`.
|
||||
/// `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)]
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -336,12 +341,13 @@ 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).await {
|
||||
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel, schema).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);
|
||||
|
||||
@@ -28,9 +28,11 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
let settings = settings.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::configure_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,
|
||||
)?;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! 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};
|
||||
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;
|
||||
|
||||
struct CratesIo;
|
||||
impl DependencyVersionSource for CratesIo {
|
||||
@@ -21,6 +25,76 @@ 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,
|
||||
@@ -31,11 +105,24 @@ pub async fn dependency_versions(
|
||||
) -> IdeResult<Vec<DependencyVersions>> {
|
||||
let project = host.project(window)?;
|
||||
let cache = host.dependency_versions.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let config_dir = host.config_dir.clone();
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = host
|
||||
.blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
|
||||
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") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,10 @@ 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 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);
|
||||
// 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);
|
||||
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.
|
||||
@@ -153,6 +154,41 @@ 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)
|
||||
}
|
||||
@@ -220,6 +256,36 @@ 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]
|
||||
|
||||
+660
-51
@@ -7,6 +7,7 @@ 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>,
|
||||
@@ -85,7 +86,12 @@ const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PluginActionRef {
|
||||
plugin: String,
|
||||
data: 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,
|
||||
}
|
||||
|
||||
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
|
||||
@@ -390,25 +396,144 @@ pub async fn lsp_references(
|
||||
libraries: bool,
|
||||
on_partial: super::Channel<References>,
|
||||
) -> IdeResult<References> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp
|
||||
.references_streaming(
|
||||
doc,
|
||||
pos,
|
||||
include_declaration,
|
||||
project_wide,
|
||||
libraries,
|
||||
cancel,
|
||||
Some(&|refs| {
|
||||
let _ = on_partial.send(refs);
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
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;
|
||||
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
|
||||
}
|
||||
@@ -443,7 +568,7 @@ pub async fn lsp_code_actions(
|
||||
};
|
||||
let plugin_actions: Vec<CodeAction> = plugin_actions
|
||||
.into_iter()
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, a))
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, &project.root, 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}."))))
|
||||
@@ -459,23 +584,26 @@ pub async fn lsp_code_actions(
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
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());
|
||||
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());
|
||||
CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
is_preferred: a.preferred,
|
||||
disabled_reason: None,
|
||||
disabled_reason,
|
||||
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
command: None,
|
||||
data,
|
||||
@@ -483,6 +611,53 @@ fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -499,11 +674,16 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
|
||||
kind: action.kind.clone(),
|
||||
preferred: action.is_preferred,
|
||||
edits: Vec::new(),
|
||||
data: Some(r.data),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: r.data,
|
||||
file_action: r.file,
|
||||
};
|
||||
let plugin = r.plugin.clone();
|
||||
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
|
||||
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
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 }))
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
@@ -526,7 +706,7 @@ pub async fn lsp_code_action_query(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Resolves a code action's edit without applying it (intention preview).
|
||||
/// Resolves a code action's edit without applying it.
|
||||
pub async fn lsp_resolve_code_action(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -554,11 +734,15 @@ 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)
|
||||
{
|
||||
@@ -567,6 +751,24 @@ 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.
|
||||
@@ -584,6 +786,14 @@ 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
|
||||
}
|
||||
|
||||
@@ -650,20 +860,133 @@ 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 = project.lsp.rename(doc, pos, new_name, cancel).await;
|
||||
host.end_request(request);
|
||||
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 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
|
||||
}
|
||||
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
|
||||
Ok(renamed)
|
||||
})
|
||||
.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;
|
||||
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 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();
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
@@ -677,7 +1000,22 @@ pub async fn lsp_format(
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
host.project(window)?.lsp.inlay_hints(doc, range).await
|
||||
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))
|
||||
}
|
||||
|
||||
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
|
||||
@@ -831,6 +1169,8 @@ 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))
|
||||
@@ -846,7 +1186,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(Vec::new()),
|
||||
None => Ok(open.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
@@ -1003,6 +1343,194 @@ 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 {
|
||||
@@ -1010,15 +1538,19 @@ mod tests {
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits,
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: data.map(str::to_owned),
|
||||
file_action: false,
|
||||
};
|
||||
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
|
||||
let root = Path::new("/p");
|
||||
let lazy = plugin_code_action("acme.x", root, 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_str()), ("acme.x", "ref"));
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_deref()), ("acme.x", Some("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", action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
let eager = plugin_code_action("acme.x", root, action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
assert!(eager.edit.is_some() && eager.data.is_none());
|
||||
}
|
||||
|
||||
@@ -1095,6 +1627,83 @@ 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");
|
||||
|
||||
@@ -9,14 +9,20 @@ use std::time::Duration;
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
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)? {
|
||||
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)? {
|
||||
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
|
||||
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective();
|
||||
let settings = ide_workspace::SettingsStore::load(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);
|
||||
@@ -24,7 +30,7 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
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 task.kind == "run" {
|
||||
if matches!(task.kind.as_str(), "run" | "build") && !task.target.is_empty() {
|
||||
task.target = format!("workspace:{}:{}", key, task.target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,16 @@ 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)?;
|
||||
@@ -212,7 +222,7 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
|
||||
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
|
||||
)?;
|
||||
plan.env.extend(config.env.clone());
|
||||
let mut build = RunConfig {
|
||||
@@ -278,7 +288,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::base_dir(&project.cache_dir, &config.id);
|
||||
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
|
||||
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
|
||||
})
|
||||
.await
|
||||
@@ -297,6 +307,7 @@ 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 {
|
||||
|
||||
@@ -6,19 +6,32 @@ use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_docker::DockerEndpoint;
|
||||
use ide_remote::docker::{
|
||||
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
|
||||
ContainerDebug, ContainerRun, DEBUG_BOOTSTRAP, DEBUG_BOOTSTRAP_NAME, 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!(
|
||||
@@ -39,6 +52,7 @@ 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 {
|
||||
@@ -50,12 +64,45 @@ 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();
|
||||
|
||||
+128
-16
@@ -45,6 +45,15 @@ 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)?;
|
||||
@@ -149,25 +158,56 @@ 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) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
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>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
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 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);
|
||||
})
|
||||
.collect();
|
||||
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
})
|
||||
};
|
||||
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()))
|
||||
}
|
||||
|
||||
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
|
||||
@@ -257,6 +297,10 @@ 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 });
|
||||
@@ -268,7 +312,9 @@ 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).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_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
});
|
||||
@@ -301,3 +347,69 @@ 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");
|
||||
}
|
||||
}
|
||||
+1136
-75
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,245 @@
|
||||
//! 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(¤t) {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! `[[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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
//! 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(©, &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(©, &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");
|
||||
}
|
||||
}
|
||||
@@ -510,6 +510,8 @@ 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());
|
||||
@@ -607,6 +609,10 @@ 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>"##,
|
||||
|
||||
@@ -83,23 +83,14 @@ pub async fn plugin_navigation_index(
|
||||
}
|
||||
(snapshot.text, Some(snapshot.version), snapshot.language)
|
||||
} 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 {
|
||||
let Ok(loaded) = project.documents.load_file_bounded(&path, super::navigation::MAX_DOCUMENT_BYTES)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut text = String::new();
|
||||
if file
|
||||
.take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64)
|
||||
.read_to_string(&mut text)
|
||||
.is_err()
|
||||
{
|
||||
if loaded.binary || loaded.large {
|
||||
continue;
|
||||
}
|
||||
let text = loaded.text;
|
||||
let language = project.languages.detect(&path, text.lines().next());
|
||||
(text, None, language)
|
||||
};
|
||||
@@ -126,6 +117,7 @@ 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.
|
||||
@@ -140,6 +132,17 @@ 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>,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,134 @@ 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())
|
||||
@@ -219,6 +347,99 @@ 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();
|
||||
|
||||
@@ -429,6 +429,7 @@ 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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,17 @@
|
||||
|
||||
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;
|
||||
@@ -44,6 +49,7 @@ 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,
|
||||
@@ -109,6 +115,7 @@ struct Links {
|
||||
}
|
||||
|
||||
struct Meta {
|
||||
uninstalling: bool,
|
||||
status: PluginStatus,
|
||||
error: Option<String>,
|
||||
warnings: Vec<String>,
|
||||
@@ -136,6 +143,7 @@ struct Meta {
|
||||
impl Meta {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
uninstalling: false,
|
||||
status: PluginStatus::Disabled,
|
||||
error: None,
|
||||
warnings: Vec::new(),
|
||||
@@ -182,6 +190,7 @@ 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.
|
||||
@@ -212,6 +221,39 @@ 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 {
|
||||
@@ -321,6 +363,9 @@ 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>,
|
||||
@@ -377,7 +422,8 @@ impl PluginHost {
|
||||
let ui = UiStore::new();
|
||||
let index = Arc::new(ContributionIndex::new(
|
||||
plugins_dir.join(".cache").join("contributions"),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
// Revalidate SVGs accepted before URL-valued CSS functions were rejected.
|
||||
concat!(env!("CARGO_PKG_VERSION"), ":svg-2"),
|
||||
));
|
||||
ui.set_index(Some(index.clone()));
|
||||
let languages = PluginLanguages::new();
|
||||
@@ -410,6 +456,9 @@ 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),
|
||||
@@ -445,6 +494,47 @@ 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 {
|
||||
@@ -698,9 +788,19 @@ 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 {
|
||||
@@ -724,6 +824,7 @@ 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);
|
||||
@@ -785,6 +886,11 @@ 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
|
||||
{
|
||||
@@ -859,6 +965,13 @@ 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) {
|
||||
@@ -902,6 +1015,7 @@ impl PluginHost {
|
||||
level: ide_plugin::NotificationLevel::Warning,
|
||||
title: format!("{name} update rolled back"),
|
||||
body: message.clone(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
self.changed();
|
||||
@@ -912,6 +1026,7 @@ 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"));
|
||||
}
|
||||
@@ -930,6 +1045,7 @@ 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"));
|
||||
}
|
||||
@@ -950,6 +1066,7 @@ 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}")));
|
||||
@@ -988,6 +1105,13 @@ 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)?;
|
||||
@@ -1006,21 +1130,65 @@ impl PluginHost {
|
||||
.flatten()
|
||||
.filter_map(|s| s.get("name")?.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
self.unload(id);
|
||||
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.
|
||||
{
|
||||
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) {
|
||||
tracing::warn!(plugin = %id, "cannot delete secret {name:?}: {e}");
|
||||
return Err(std::io::Error::other(format!("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());
|
||||
@@ -1034,6 +1202,12 @@ 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(())
|
||||
}
|
||||
@@ -1080,8 +1254,7 @@ impl PluginHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// The Services pane of node `key` of the plugin's `location = "services"`
|
||||
/// view `view`.
|
||||
/// The detail tabs of item `key` of a Services or `detail = true` view.
|
||||
pub fn service_detail(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -1089,9 +1262,9 @@ impl PluginHost {
|
||||
key: &str,
|
||||
invocation: Invocation,
|
||||
) -> IdeResult<Vec<ide_plugin::ServiceTab>> {
|
||||
self.services_view(id, view)?;
|
||||
self.detail_view(id, view)?;
|
||||
let (view, key) = (view.to_owned(), key.to_owned());
|
||||
self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| {
|
||||
self.code_call(id, invocation, "rendering the item's detail pane", move |rt, inv| {
|
||||
rt.service_detail(&view, &key, inv)
|
||||
})
|
||||
}
|
||||
@@ -1147,16 +1320,16 @@ impl PluginHost {
|
||||
if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) }
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}")))
|
||||
Err(IdeError::not_found(format!("plugin {id:?} declares no detail view {view:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1349,11 +1522,12 @@ impl PluginHost {
|
||||
self.shared.reopen_client(label);
|
||||
}
|
||||
|
||||
/// Window `label` closed: drops its context keys and queued events, and ignores
|
||||
/// context writes of commands still running for it.
|
||||
/// Window `label` closed: cancels its prompts, 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`).
|
||||
@@ -1435,7 +1609,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.get(p.id())) {
|
||||
if Self::wants(p, kind, subs.kinds.get(p.id())) {
|
||||
out.insert(kind);
|
||||
}
|
||||
}
|
||||
@@ -1445,7 +1619,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(&self, plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet<EventKind>>) -> bool {
|
||||
fn wants(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)) {
|
||||
@@ -1459,6 +1633,15 @@ 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;
|
||||
}
|
||||
@@ -1466,31 +1649,60 @@ 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.get(p.id()))).cloned().collect();
|
||||
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);
|
||||
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() });
|
||||
queue.events.extend(wanted);
|
||||
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 excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
|
||||
queue.events.drain(..excess);
|
||||
let mut remaining = excess;
|
||||
queue.events.retain(|event| {
|
||||
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
|
||||
remaining -= 1;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. Call it a few times a second.
|
||||
/// don't keep waking them. Filesystem events are already debounced by
|
||||
/// the watcher and bypass that delay. 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, Vec<WorkbenchEvent>)> = {
|
||||
let due: Vec<(QueueKey, EventQueue)> = {
|
||||
let mut queues = self.queues.lock();
|
||||
let plugins = self.plugins.read();
|
||||
let keys: Vec<_> = queues
|
||||
@@ -1501,15 +1713,17 @@ impl PluginHost {
|
||||
running
|
||||
|| now.saturating_duration_since(q.since) >= IDLE_BATCH
|
||||
|| q.events.len() >= MAX_QUEUED_EVENTS / 2
|
||||
|| q.events.contains(&WorkbenchEvent::Scheduled)
|
||||
|| q.events
|
||||
.iter()
|
||||
.any(|e| matches!(e, WorkbenchEvent::FilesChanged(_) | WorkbenchEvent::Scheduled))
|
||||
})
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q.events))).collect()
|
||||
keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q))).collect()
|
||||
};
|
||||
let mut changed = false;
|
||||
for ((id, client), events) in due {
|
||||
changed |= self.deliver(&id, client, events);
|
||||
for ((id, client), queue) in due {
|
||||
changed |= self.deliver(&id, client, queue);
|
||||
}
|
||||
// Batches that crashed since.
|
||||
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
|
||||
@@ -1542,12 +1756,52 @@ 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>, events: Vec<WorkbenchEvent>) -> bool {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1557,21 +1811,94 @@ impl PluginHost {
|
||||
client: client.clone(),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
};
|
||||
let kinds: BTreeSet<EventKind> = events.iter().map(WorkbenchEvent::kind).collect();
|
||||
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 mut batches = plugin.batches.lock();
|
||||
if batches.running.as_ref() == Some(&kinds) && !kinds.contains(&EventKind::Scheduled) {
|
||||
if batches.running.as_ref() == Some(&kinds)
|
||||
&& !kinds.contains(&EventKind::Scheduled)
|
||||
&& !kinds.contains(&EventKind::FilesChanged)
|
||||
{
|
||||
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, finish) = (plugin.batches.clone(), plugin.batches.clone());
|
||||
let start = plugin.batches.clone();
|
||||
let ticket = BatchTicket {
|
||||
batches: plugin.batches.clone(),
|
||||
files_key: filesystem.then(|| mailbox_key.clone()),
|
||||
token: token.clone(),
|
||||
};
|
||||
let done_plugin = plugin.clone();
|
||||
let delivery_plugin = plugin.clone();
|
||||
let shared = self.shared.clone();
|
||||
actor.post(
|
||||
move |rt| {
|
||||
start.lock().running = Some(kinds);
|
||||
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(());
|
||||
}
|
||||
rt.handle_events(events, invocation)
|
||||
},
|
||||
move |result| {
|
||||
@@ -1588,9 +1915,6 @@ impl PluginHost {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut batches = finish.lock();
|
||||
batches.pending = batches.pending.saturating_sub(1);
|
||||
batches.running = None;
|
||||
},
|
||||
);
|
||||
activated
|
||||
@@ -1733,6 +2057,7 @@ 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);
|
||||
@@ -1740,6 +2065,7 @@ impl PluginHost {
|
||||
for a in actors {
|
||||
a.stop(true);
|
||||
}
|
||||
self.shared.finish_process_shutdowns();
|
||||
}
|
||||
|
||||
/// Periodic upkeep (every few seconds): reports crashes nobody waited
|
||||
@@ -1786,7 +2112,7 @@ impl PluginHost {
|
||||
self.queues
|
||||
.lock()
|
||||
.entry((p.id().to_owned(), None))
|
||||
.or_insert_with(|| EventQueue { since: now, events: Vec::new() })
|
||||
.or_insert_with(|| EventQueue { since: now, events: Vec::new(), files_source: None })
|
||||
.events
|
||||
.push(WorkbenchEvent::Scheduled);
|
||||
}
|
||||
@@ -1854,7 +2180,17 @@ 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) => (placeholder_manifest(dir), Some(e)),
|
||||
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))
|
||||
}
|
||||
};
|
||||
let invalid = error.is_some();
|
||||
let id = manifest.id.clone();
|
||||
@@ -1904,10 +2240,27 @@ 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())
|
||||
{
|
||||
@@ -1965,6 +2318,7 @@ 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));
|
||||
@@ -2073,6 +2427,7 @@ 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();
|
||||
@@ -2089,6 +2444,7 @@ 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;
|
||||
@@ -2118,6 +2474,7 @@ 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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2134,6 +2491,8 @@ 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;
|
||||
@@ -2144,28 +2503,36 @@ 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);
|
||||
@@ -2179,6 +2546,7 @@ 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();
|
||||
@@ -2523,6 +2891,70 @@ 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"));
|
||||
|
||||
@@ -28,6 +28,7 @@ pub use ide_plugin::indexing::*;
|
||||
struct File {
|
||||
version: Option<u32>,
|
||||
scope: String,
|
||||
hash: blake3::Hash,
|
||||
edges: Edges,
|
||||
}
|
||||
#[derive(Default)]
|
||||
@@ -61,6 +62,11 @@ 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()
|
||||
@@ -303,7 +309,7 @@ impl PluginHost {
|
||||
{
|
||||
return Err(IdeError::unavailable("navigation association limit reached"));
|
||||
}
|
||||
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
|
||||
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), hash: snapshot_hash(text), edges });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -365,6 +371,70 @@ 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,
|
||||
@@ -423,6 +493,24 @@ 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 {
|
||||
|
||||
@@ -59,6 +59,7 @@ 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)))?;
|
||||
@@ -66,8 +67,10 @@ 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(())
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
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};
|
||||
|
||||
@@ -39,6 +40,8 @@ pub enum ProviderKind {
|
||||
LineMarkers,
|
||||
SemanticTokens,
|
||||
Hover,
|
||||
InlayHints,
|
||||
Diagnostics,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -72,13 +75,26 @@ impl Providers {
|
||||
|
||||
/// Plugins providing `kind` for `language`, in declaration order.
|
||||
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
/// Whether any plugin provides `kind` (for any language).
|
||||
@@ -117,9 +133,15 @@ 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()
|
||||
@@ -165,11 +187,11 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
|
||||
|
||||
// ---- dispatch ---------------------------------------------------------------
|
||||
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus};
|
||||
use super::{LoadedPlugin, PluginEvent, PluginHost, PluginStatus};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::{
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
|
||||
ProviderDocument, Runtime, SemanticToken,
|
||||
PluginInlayHint, ProviderDocument, Runtime, SemanticToken,
|
||||
};
|
||||
use std::sync::Weak;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -198,7 +220,7 @@ pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
|
||||
|
||||
impl PluginHost {
|
||||
/// Usable plugins declaring `kind` for `language`.
|
||||
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
|
||||
pub(super) fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
|
||||
self.providers
|
||||
.plugins_for(kind, language)
|
||||
.into_iter()
|
||||
@@ -311,14 +333,21 @@ 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, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
|
||||
rt.resolve_code_action(&d, &action, timeout)
|
||||
self.code_call(plugin_id, invocation, "resolving the code action", move |rt, inv| {
|
||||
rt.resolve_code_action(&d, &action, inv, 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| {
|
||||
@@ -349,6 +378,59 @@ 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))
|
||||
@@ -357,6 +439,16 @@ 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.
|
||||
@@ -385,3 +477,41 @@ 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"]);
|
||||
}
|
||||
}
|
||||
@@ -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_view_render`, `plugin_view_event`, `plugin_service_detail`,
|
||||
//! `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
|
||||
//! `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`)
|
||||
//! 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,6 +31,7 @@ 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};
|
||||
@@ -117,12 +118,15 @@ 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 { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -374,16 +378,26 @@ 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 {
|
||||
@@ -558,6 +572,11 @@ 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
|
||||
});
|
||||
|
||||
@@ -168,11 +168,22 @@ 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 },
|
||||
Notification {
|
||||
plugin_id: String,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
/// Buttons running the plugin's own commands (`ui.notify`).
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
},
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
@@ -213,6 +224,10 @@ 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,
|
||||
@@ -222,6 +237,9 @@ 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")]
|
||||
|
||||
@@ -53,14 +53,14 @@
|
||||
//! path = "templates/drawing.excalidraw"
|
||||
//! ```
|
||||
|
||||
use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when};
|
||||
use ide_extension::{LOCALES_DIR, Package, UiDecl, decode, validate_item_id, validate_when};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A view whose tree shows in the Services tool window; the plugin's
|
||||
/// `service-provider` renders the selected node's pane.
|
||||
/// `detail-provider` renders the selected node's pane.
|
||||
pub const SERVICES_LOCATION: &str = "services";
|
||||
pub const MENUS: &str = "menus";
|
||||
pub const KEYMAPS: &str = "keymaps";
|
||||
@@ -69,6 +69,7 @@ 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";
|
||||
|
||||
@@ -122,6 +123,10 @@ 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> {
|
||||
@@ -136,6 +141,13 @@ 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)) => {
|
||||
@@ -271,12 +283,60 @@ 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 {
|
||||
properties: serde_json::Map<String, Value>,
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// One setting of a plugin.
|
||||
@@ -284,7 +344,7 @@ struct SchemaFile {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingSchema {
|
||||
pub key: String,
|
||||
/// `boolean`, `string`, `number` or `integer`.
|
||||
/// A scalar, top-level `array`, or `object` table row.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
@@ -295,6 +355,12 @@ 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 {
|
||||
@@ -304,6 +370,13 @@ 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))),
|
||||
@@ -311,17 +384,47 @@ impl SettingSchema {
|
||||
}
|
||||
}
|
||||
|
||||
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
|
||||
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> {
|
||||
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 o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
|
||||
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 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)),
|
||||
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
|
||||
"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:?}")),
|
||||
};
|
||||
let options = match o.get("enum") {
|
||||
None => None,
|
||||
@@ -347,6 +450,9 @@ fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
|
||||
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"));
|
||||
@@ -365,13 +471,68 @@ 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)?;
|
||||
let text = pkg.read_text(&d.schema)?;
|
||||
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
|
||||
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 title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
|
||||
title_ok(&title)?;
|
||||
let mut decl =
|
||||
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
|
||||
decl.files.push(d.schema);
|
||||
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;
|
||||
Ok(decl)
|
||||
}
|
||||
|
||||
@@ -388,6 +549,16 @@ 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>,
|
||||
@@ -408,6 +579,15 @@ 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));
|
||||
@@ -418,6 +598,24 @@ 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));
|
||||
}
|
||||
@@ -529,17 +727,56 @@ 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.
|
||||
@@ -548,8 +785,59 @@ 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()
|
||||
if (ext.is_empty() && d.file_name.is_none())
|
||||
|| ext.len() > 32
|
||||
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||
{
|
||||
@@ -559,7 +847,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 });
|
||||
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body, "fileName": d.file_name, "variables": variables });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
|
||||
out.files = vec![d.path.clone()];
|
||||
Ok(out)
|
||||
@@ -588,6 +876,57 @@ 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::*;
|
||||
@@ -619,4 +958,139 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,23 @@ 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>,
|
||||
@@ -107,6 +124,203 @@ 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]
|
||||
|
||||
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -27,6 +28,7 @@ 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,
|
||||
@@ -193,6 +195,7 @@ 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))
|
||||
@@ -213,13 +216,19 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
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::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
&data_dir,
|
||||
&jdks,
|
||||
ide_sdk::apply::project_jdk(root, &effective).as_ref(),
|
||||
ide_sdk::apply::module_jdk(root, server_root, &effective).as_ref(),
|
||||
server_jdk.as_ref(),
|
||||
min_java,
|
||||
&bundles,
|
||||
@@ -311,7 +320,10 @@ 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();
|
||||
@@ -347,6 +359,10 @@ 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).
|
||||
@@ -509,7 +525,11 @@ 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,
|
||||
@@ -539,6 +559,7 @@ impl Host {
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
files_events_active,
|
||||
light: false,
|
||||
});
|
||||
{
|
||||
@@ -655,7 +676,11 @@ 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,
|
||||
@@ -684,6 +709,7 @@ 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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! `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());
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,14 @@ 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");
|
||||
@@ -34,7 +40,7 @@ impl TrustStore {
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state) }
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
@@ -45,7 +51,19 @@ 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))))
|
||||
&& (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(())
|
||||
}
|
||||
|
||||
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
@@ -300,6 +318,7 @@ 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"
|
||||
|
||||
@@ -58,7 +58,13 @@ fn fixture() -> Fixture {
|
||||
}
|
||||
|
||||
fn inv() -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() }
|
||||
Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: None,
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn misbehave() -> PathBuf {
|
||||
|
||||
@@ -117,6 +117,41 @@ 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,3 +157,150 @@ 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();
|
||||
}
|
||||
@@ -232,7 +232,8 @@ fn the_commit_points_need_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host =
|
||||
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
|
||||
// The manifest surgery below matches on `\n`; normalize so a CRLF checkout (Windows) behaves the same.
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap().replace("\r\n", "\n");
|
||||
for (what, manifest) in [
|
||||
(
|
||||
"commit.message",
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -140,6 +140,21 @@ 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();
|
||||
@@ -176,6 +191,7 @@ 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();
|
||||
@@ -239,3 +255,42 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! 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());
|
||||
}
|
||||
+2383
-13
File diff suppressed because it is too large.
Load diff
@@ -164,6 +164,9 @@ 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();
|
||||
@@ -230,7 +233,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")] {
|
||||
for dir in [official("csv-editor"), fixture("jobs"), fixture("settings-pages")] {
|
||||
let info = desktop.install_from_dir(&dir, &Consent::all()).unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
}
|
||||
@@ -266,8 +269,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"]).await;
|
||||
assert_eq!(report.installed.len(), 2, "{report:?}");
|
||||
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs", "settings-pages"]).await;
|
||||
assert_eq!(report.installed.len(), 3, "{report:?}");
|
||||
|
||||
// CSV Editor: the document is the server's.
|
||||
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
|
||||
@@ -326,5 +329,24 @@ 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();
|
||||
}
|
||||
@@ -211,6 +211,24 @@ 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;
|
||||
|
||||
@@ -268,6 +268,43 @@ 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)
|
||||
|
||||
@@ -121,22 +121,35 @@ 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)
|
||||
.fold(Vec::new, |mut acc, (slot, e)| {
|
||||
.try_fold(Vec::new, |mut acc, (slot, e)| {
|
||||
if stopped() {
|
||||
return None;
|
||||
}
|
||||
if let Some(e) = e {
|
||||
per_file(slot as u32, e, &mut acc);
|
||||
if acc.len() >= pool * 2 {
|
||||
acc = keep_best(acc, pool, tiebreak);
|
||||
}
|
||||
}
|
||||
acc
|
||||
Some(acc)
|
||||
})
|
||||
.reduce(Vec::new, |mut a, b| {
|
||||
.try_reduce(Vec::new, |mut a, b| {
|
||||
a.extend(b);
|
||||
if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a }
|
||||
Some(if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -423,9 +436,21 @@ 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 Vec::new();
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let (container_q, name_q) = split_symbol_query(&q);
|
||||
let name = Needle::new(name_q);
|
||||
@@ -436,13 +461,16 @@ pub(crate) fn find_symbols(
|
||||
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(data, pool, &tiebreak, |slot, e, acc| {
|
||||
if e.symbols.is_empty() {
|
||||
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
|
||||
if e.symbols.is_empty() || !include(&e.path) {
|
||||
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;
|
||||
}
|
||||
@@ -451,11 +479,15 @@ pub(crate) fn find_symbols(
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})?;
|
||||
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);
|
||||
@@ -472,7 +504,8 @@ pub(crate) fn find_symbols(
|
||||
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);
|
||||
out.into_iter()
|
||||
let results = out
|
||||
.into_iter()
|
||||
.map(|(score, c, indices)| {
|
||||
let e = file(data, c.slot);
|
||||
let s = &e.symbols[c.sym as usize];
|
||||
@@ -487,7 +520,8 @@ pub(crate) fn find_symbols(
|
||||
indices,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(!stopped()).then_some(results)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
|
||||
|
||||
@@ -652,3 +652,80 @@ 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");
|
||||
}
|
||||
@@ -174,7 +174,10 @@ impl Shared {
|
||||
Ok(records) => {
|
||||
let entries: Vec<FileEntry> = records
|
||||
.into_par_iter()
|
||||
.filter(|(rel, _)| !filter.is_excluded(&root.join(rel), false))
|
||||
.filter(|(rel, _)| {
|
||||
let path = root.join(rel);
|
||||
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
|
||||
})
|
||||
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
|
||||
.collect();
|
||||
let mut d = self.data.write();
|
||||
@@ -420,7 +423,10 @@ impl Shared {
|
||||
let mut seen = HashSet::new();
|
||||
let mut by_dir: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
|
||||
for path in changed {
|
||||
if !seen.insert(path.clone()) {
|
||||
// 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()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
|
||||
|
||||
@@ -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.
|
||||
fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
|
||||
pub 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,8 +22,49 @@ 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.
|
||||
@@ -68,6 +109,134 @@ 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("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
fn yaml(
|
||||
@@ -317,4 +486,107 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ impl ClientHandler {
|
||||
}
|
||||
};
|
||||
let ended = matches!(&event, IdeEvent::Task { done: true, .. });
|
||||
mgr.sink().emit(event);
|
||||
self.server.emit(mgr.sink(), event);
|
||||
self.server.refresh_busy_status(mgr.sink());
|
||||
if ended {
|
||||
self.server.progress_ended();
|
||||
|
||||
+228
-5
@@ -41,6 +41,12 @@ 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>>) {
|
||||
@@ -54,6 +60,115 @@ 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,
|
||||
@@ -104,11 +219,13 @@ 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('=')
|
||||
&& ACCESS_OPTIONS.contains(&key.trim())
|
||||
&& ["error", "warning", "info", "ignore"].contains(&value.trim())
|
||||
{
|
||||
options.insert(key.trim().into(), json!(value.trim()));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(options)
|
||||
@@ -978,4 +1095,110 @@ 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"));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,16 @@ 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),
|
||||
@@ -49,6 +59,9 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -158,6 +171,16 @@ 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);
|
||||
}
|
||||
@@ -178,7 +201,8 @@ impl LspManager {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await;
|
||||
let result =
|
||||
self.configuration_query(snap, pos, key, placeholder.is_some(), usages, include_declaration, cancel).await;
|
||||
match result {
|
||||
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()),
|
||||
Err(e) => {
|
||||
@@ -189,22 +213,63 @@ 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>> {
|
||||
let (server, run) = self.jdtls(&snap.path, cancel).await?;
|
||||
// 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),
|
||||
};
|
||||
if !run.caps.read().commands().iter().any(|command| command == COMMAND) {
|
||||
return Ok(None);
|
||||
return Ok(if placeholder { local } else { None });
|
||||
}
|
||||
let target = Target { server: server.clone(), run };
|
||||
let position = snap.text.ide_to_lsp(Encoding::Utf16, pos);
|
||||
let response: Response = self
|
||||
let response: Response = match self
|
||||
.call(
|
||||
&target,
|
||||
"workspace/executeCommand",
|
||||
@@ -214,11 +279,17 @@ impl LspManager {
|
||||
Some(cancel),
|
||||
crate::LONG_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
.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),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if response.keys.is_empty() {
|
||||
if response.keys.is_empty() && !placeholder {
|
||||
return Ok(response
|
||||
.incomplete
|
||||
.then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() }));
|
||||
@@ -229,22 +300,18 @@ impl LspManager {
|
||||
..Default::default()
|
||||
};
|
||||
self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref()));
|
||||
if key.is_none() || usages {
|
||||
if let Some(local) = local {
|
||||
result.locations.extend(local.locations);
|
||||
result.incomplete = result.incomplete.or(local.incomplete);
|
||||
}
|
||||
if key.is_none() || usages || placeholder {
|
||||
let roots: Vec<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
|
||||
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"))?;
|
||||
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?;
|
||||
result.locations.extend(files.locations);
|
||||
result.incomplete = result.incomplete.or(files.incomplete);
|
||||
}
|
||||
@@ -266,6 +333,29 @@ 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 {
|
||||
@@ -296,11 +386,15 @@ fn configuration_files(
|
||||
) -> References {
|
||||
let mut result = References::default();
|
||||
let mut paths = std::collections::BTreeSet::new();
|
||||
// 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 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();
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.follow_links(false)
|
||||
.filter_entry(move |entry| {
|
||||
@@ -324,21 +418,26 @@ fn configuration_files(
|
||||
if cancel.is_cancelled() {
|
||||
return result;
|
||||
}
|
||||
if visited >= 50_000 || paths.len() >= MAX_FILES {
|
||||
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
|
||||
if visited >= 50_000 || found.paths.len() >= MAX_FILES {
|
||||
found.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() => {
|
||||
paths.insert(entry.into_path());
|
||||
found.paths.push(entry.into_path());
|
||||
}
|
||||
Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
|
||||
Err(_) => found.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();
|
||||
}
|
||||
|
||||
+99
-115
@@ -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.announce_down();
|
||||
server.begin_stop(&self.sink, true);
|
||||
self.forget(std::slice::from_ref(server));
|
||||
return;
|
||||
}
|
||||
@@ -526,11 +526,10 @@ impl LspManager {
|
||||
}
|
||||
|
||||
async fn run_instance(self: Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Starting, None);
|
||||
let Some(cancel) = server.starting_status(generation, &self.sink) else { return };
|
||||
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 || {
|
||||
@@ -541,7 +540,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(cfg.clone());
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if !cancel.is_cancelled()
|
||||
&& me.launcher.available(&cfg, &tail.root)
|
||||
@@ -550,7 +549,7 @@ impl LspManager {
|
||||
prepare(&cfg, &tail.root, &cancel);
|
||||
cfg = me.configured(&tail);
|
||||
}
|
||||
tail.set_effective_config(cfg.clone());
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
let root = tail.root.clone();
|
||||
if let Some(message) = &cfg.launch_error {
|
||||
// A missing binary still asks to be installed first.
|
||||
@@ -560,14 +559,23 @@ 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(line)), &cancel)
|
||||
me.launcher.launch_resolved(
|
||||
&cfg,
|
||||
&root,
|
||||
handler,
|
||||
Box::new(move |line| tail.push_stderr(generation, line)),
|
||||
&cancel,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string())));
|
||||
|
||||
let peer = match launched {
|
||||
Ok((peer, binary)) => {
|
||||
*server.resolved_binary.write() = binary;
|
||||
if !server.set_resolved_binary(generation, binary) {
|
||||
peer.kill();
|
||||
return;
|
||||
}
|
||||
peer
|
||||
}
|
||||
Err(LaunchError::Missing) => {
|
||||
@@ -649,7 +657,10 @@ impl LspManager {
|
||||
};
|
||||
let run =
|
||||
Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() };
|
||||
server.set_initializing(Some(run.clone()));
|
||||
if !server.set_initializing(run.clone()) {
|
||||
peer.kill();
|
||||
return;
|
||||
}
|
||||
if let Err(e) = peer.notify("initialized", json!({})) {
|
||||
tracing::warn!("{}: {e}", server.name);
|
||||
}
|
||||
@@ -670,7 +681,6 @@ impl LspManager {
|
||||
let mut docs = self.docs.lock();
|
||||
if !server.mark_ready(generation, run.clone()) {
|
||||
drop(docs);
|
||||
server.set_initializing(None);
|
||||
peer.kill();
|
||||
return;
|
||||
}
|
||||
@@ -706,79 +716,20 @@ impl LspManager {
|
||||
}
|
||||
|
||||
async fn on_instance_closed(self: Arc<Self>, server: Arc<Server>, generation: u64, code: Option<i32>) {
|
||||
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();
|
||||
if self.shutting_down.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
let Some(delay) = server.on_closed(generation, &self.sink, code) else { return };
|
||||
self.file_watch_roots_changed();
|
||||
if !server.root.is_dir() {
|
||||
server.announce_down();
|
||||
self.forget(std::slice::from_ref(&server));
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
if let Some(delay) = delay {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1385,14 +1336,8 @@ impl LspManager {
|
||||
let mut first = Vec::new();
|
||||
if method == "textDocument/implementation" {
|
||||
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
|
||||
// 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 complete && !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
if let Some(partial) = partial
|
||||
&& !locs.is_empty()
|
||||
@@ -1450,9 +1395,9 @@ impl LspManager {
|
||||
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
|
||||
}
|
||||
|
||||
/// [`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.
|
||||
/// [`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.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn references_streaming(
|
||||
&self,
|
||||
@@ -1481,7 +1426,20 @@ impl LspManager {
|
||||
};
|
||||
let native =
|
||||
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
|
||||
let (configuration, result) = futures::join!(configuration, native);
|
||||
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 = configuration?;
|
||||
let mut result = result?;
|
||||
if let Some(configuration) = configuration {
|
||||
@@ -1605,10 +1563,7 @@ impl LspManager {
|
||||
};
|
||||
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
|
||||
} else {
|
||||
// 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 first = query(false).await?;
|
||||
let empty = first.is_empty();
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
@@ -2104,7 +2059,7 @@ impl LspManager {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(Renamed { edit: out.finish(WorkspaceEdit::default())?, incomplete })
|
||||
Ok(Renamed { preview: false, edit: out.finish(WorkspaceEdit::default())?, incomplete })
|
||||
}
|
||||
|
||||
/// The servers that can rename at `pos`, the first one when none can (a
|
||||
@@ -2141,7 +2096,7 @@ impl LspManager {
|
||||
let edit = query().await?;
|
||||
let empty = edit.files.is_empty();
|
||||
let w = scope::Widened::only((), empty, &t.server);
|
||||
return Ok(Renamed { edit, incomplete: w.incomplete });
|
||||
return Ok(Renamed { preview: false, edit, incomplete: w.incomplete });
|
||||
}
|
||||
let files = |e: &WorkspaceEdit| {
|
||||
e.files
|
||||
@@ -2167,7 +2122,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 { edit, incomplete: w.incomplete })
|
||||
Ok(Renamed { preview: false, edit, incomplete: w.incomplete })
|
||||
}
|
||||
|
||||
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
|
||||
@@ -2310,6 +2265,9 @@ 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();
|
||||
@@ -2330,6 +2288,13 @@ 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],
|
||||
@@ -2338,20 +2303,47 @@ 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, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
|
||||
.collect()
|
||||
all.filter(|(p, change)| {
|
||||
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
|
||||
.filter(|(p, change)| watchers.wants(&s.root, p, *change))
|
||||
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
|
||||
.collect()
|
||||
};
|
||||
drop(watchers);
|
||||
@@ -2925,7 +2917,7 @@ impl LspManager {
|
||||
&& without_settings(&next) == without_settings(¤t)
|
||||
&& !jdtls::source_paths_removed(¤t, &next)
|
||||
{
|
||||
s.set_effective_config(next);
|
||||
s.set_effective_config(run.generation, 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);
|
||||
@@ -3064,11 +3056,10 @@ 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();
|
||||
self.sink.emit(ide_core::IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: server.name.clone(),
|
||||
items,
|
||||
});
|
||||
server.emit(
|
||||
&self.sink,
|
||||
ide_core::IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3116,20 +3107,14 @@ 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() else {
|
||||
if final_stop {
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(run) = server.begin_stop(&self.sink, final_stop) else { 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.clear_server_state(server);
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
|
||||
server.end_stop(run.generation, &self.sink);
|
||||
self.file_watch_roots_changed();
|
||||
return;
|
||||
}
|
||||
let graceful = async {
|
||||
@@ -3143,9 +3128,8 @@ impl LspManager {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), peer.closed()).await;
|
||||
}
|
||||
peer.kill();
|
||||
server.end_stop(run.generation);
|
||||
self.clear_server_state(server);
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
|
||||
server.end_stop(run.generation, &self.sink);
|
||||
self.file_watch_roots_changed();
|
||||
}
|
||||
|
||||
pub async fn shutdown_all(&self) {
|
||||
|
||||
@@ -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();
|
||||
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
|
||||
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
|
||||
}
|
||||
}
|
||||
@@ -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 did not cover are opened on the server for a second request, then
|
||||
//! closed again. Diagnostics the server publishes for them are dropped.
|
||||
//! 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.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::text::DocText;
|
||||
@@ -29,6 +29,8 @@ 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)]
|
||||
@@ -103,7 +105,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Runs `query` on `t`; then again with the project files that mention the
|
||||
/// symbol at `pos` and are not in `covered(first)` opened on the server.
|
||||
/// symbol at `pos` outside `covered(first)` and the server's analysis scope opened.
|
||||
/// A first answer that is empty while the server indexes is retried once
|
||||
/// indexing ends.
|
||||
pub(crate) async fn widened<R, F, Fut>(
|
||||
@@ -146,6 +148,10 @@ 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 });
|
||||
}
|
||||
@@ -174,6 +180,27 @@ 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.
|
||||
|
||||
+329
-56
@@ -52,6 +52,8 @@ 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
|
||||
@@ -136,7 +138,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 }),
|
||||
state: Mutex::new(State { generation: 0, conn: Conn::Idle, events: VecDeque::new(), publishing: false }),
|
||||
initializing: Mutex::new(None),
|
||||
start_cancel: Mutex::new(CancellationToken::new()),
|
||||
phase: watch::channel(Phase::Down).0,
|
||||
@@ -175,8 +177,20 @@ impl Server {
|
||||
config
|
||||
}
|
||||
|
||||
pub fn set_effective_config(&self, config: LanguageServerConfig) {
|
||||
*self.effective.lock() = Some(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 add_language(&self, language: &LanguageId) {
|
||||
@@ -217,15 +231,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;
|
||||
}
|
||||
st.conn = conn;
|
||||
let mut st = self.state.lock();
|
||||
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
|
||||
return false;
|
||||
}
|
||||
self.set_status(sink, status, message);
|
||||
self.announce_down();
|
||||
st.conn = conn;
|
||||
self.queue_status(&mut st, status, message);
|
||||
self.phase.send_replace(Phase::Down);
|
||||
drop(st);
|
||||
self.publish(sink);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -236,8 +250,7 @@ impl Server {
|
||||
return false;
|
||||
}
|
||||
st.conn = Conn::Ready(run);
|
||||
drop(st);
|
||||
self.set_initializing(None);
|
||||
*self.initializing.lock() = None;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -249,12 +262,6 @@ 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()),
|
||||
@@ -272,8 +279,13 @@ impl Server {
|
||||
self.instance().filter(|r| r.generation == generation)
|
||||
}
|
||||
|
||||
pub fn set_initializing(&self, run: Option<Running>) {
|
||||
*self.initializing.lock() = run;
|
||||
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
|
||||
}
|
||||
|
||||
/// The running instance if it is still generation `generation`.
|
||||
@@ -290,7 +302,7 @@ impl Server {
|
||||
|
||||
/// Ready → Stopping, returning the instance to shut down. An in-flight
|
||||
/// start is abandoned instead.
|
||||
pub fn begin_stop(&self) -> Option<Running> {
|
||||
pub fn begin_stop(&self, sink: &SharedSink, final_stop: bool) -> Option<Running> {
|
||||
let mut st = self.state.lock();
|
||||
match std::mem::replace(&mut st.conn, Conn::Idle) {
|
||||
Conn::Ready(run) => {
|
||||
@@ -301,11 +313,20 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -316,28 +337,50 @@ impl Server {
|
||||
self.start_cancel.lock().clone()
|
||||
}
|
||||
|
||||
pub fn end_stop(&self, generation: u64) {
|
||||
pub fn end_stop(&self, generation: u64, sink: &SharedSink) {
|
||||
let mut st = self.state.lock();
|
||||
if st.generation == generation && matches!(st.conn, Conn::Stopping) {
|
||||
st.conn = Conn::Idle;
|
||||
if st.generation != generation || !matches!(st.conn, Conn::Stopping) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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>> {
|
||||
let mut st = self.state.lock();
|
||||
if st.generation != generation {
|
||||
if st.generation != generation || !matches!(st.conn, Conn::Ready(_) | Conn::Starting) {
|
||||
return None;
|
||||
}
|
||||
match &st.conn {
|
||||
Conn::Ready(_) | Conn::Starting => {
|
||||
st.conn = Conn::Idle;
|
||||
Some(false)
|
||||
}
|
||||
_ => Some(true),
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
@@ -345,13 +388,6 @@ 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).
|
||||
@@ -367,8 +403,8 @@ impl Server {
|
||||
}
|
||||
|
||||
pub fn reset_for_restart(&self) {
|
||||
*self.crashes.lock() = 0;
|
||||
let mut st = self.state.lock();
|
||||
*self.crashes.lock() = 0;
|
||||
if matches!(st.conn, Conn::Missing | Conn::Failed) {
|
||||
st.conn = Conn::Idle;
|
||||
}
|
||||
@@ -384,24 +420,83 @@ impl Server {
|
||||
self.status.lock().1.clone()
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
*cur = (status, message.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;
|
||||
}
|
||||
sink.emit(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
|
||||
*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;
|
||||
return;
|
||||
};
|
||||
drop(st);
|
||||
sink.emit(event);
|
||||
st = self.state.lock();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Ready ⇄ Busy depending on active `$/progress` tokens.
|
||||
pub fn refresh_busy_status(&self, sink: &SharedSink) {
|
||||
if self.running().is_none() {
|
||||
let mut st = self.state.lock();
|
||||
if !matches!(st.conn, Conn::Ready(_)) {
|
||||
return;
|
||||
}
|
||||
let busy = self.is_busy();
|
||||
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
|
||||
self.queue_status(&mut st, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
|
||||
drop(st);
|
||||
self.publish(sink);
|
||||
}
|
||||
|
||||
/// Whether the server reports work in progress (indexing, analysis).
|
||||
@@ -522,7 +617,11 @@ impl Server {
|
||||
|
||||
// ---- bookkeeping ----
|
||||
|
||||
pub fn push_stderr(&self, line: String) {
|
||||
pub fn push_stderr(&self, generation: u64, line: String) {
|
||||
let st = self.state.lock();
|
||||
if st.generation != generation {
|
||||
return;
|
||||
}
|
||||
let mut buf = self.stderr.lock();
|
||||
if buf.len() == STDERR_LINES {
|
||||
buf.pop_front();
|
||||
@@ -679,6 +778,49 @@ 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"))
|
||||
@@ -745,12 +887,143 @@ 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.
|
||||
|
||||
@@ -369,6 +369,8 @@ 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)]
|
||||
@@ -389,6 +391,10 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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; }
|
||||
}
|
||||
@@ -422,7 +422,8 @@ 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");
|
||||
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
|
||||
// `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"));
|
||||
// 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");
|
||||
@@ -448,6 +449,197 @@ 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
|
||||
@@ -1891,11 +2083,10 @@ async fn java_implementations_come_from_sources_first() {
|
||||
.collect::<Value>()
|
||||
};
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
|
||||
// Lines 0 and 1 are declared in the sources; 2 and 3 in a dependency, which implements 2 on line 3.
|
||||
// Line 0 is declared in the sources; line 2 in a dependency, which implements it 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])),
|
||||
@@ -1926,14 +2117,7 @@ async fn java_implementations_come_from_sources_first() {
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// 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.
|
||||
// None in the sources: the standard request, with the dependencies.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
@@ -2938,6 +3122,112 @@ 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();
|
||||
@@ -3322,17 +3612,9 @@ 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 with the libraries search everything at once.
|
||||
// Scoped probes (code lenses) 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")]
|
||||
@@ -4144,6 +4426,52 @@ 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();
|
||||
@@ -4165,6 +4493,198 @@ 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();
|
||||
|
||||
@@ -112,6 +112,146 @@ 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('&', "&").replace('"', """).replace('<', "<");
|
||||
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,6 +13,7 @@
|
||||
//! 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;
|
||||
@@ -20,6 +21,17 @@ 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 {
|
||||
@@ -85,15 +97,24 @@ 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) = (alive.clone(), busy.clone(), last_used.clone());
|
||||
let (alive, busy, last_used, crash) = (alive.clone(), busy.clone(), last_used.clone(), crash.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);
|
||||
job(&mut slot);
|
||||
// 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;
|
||||
}
|
||||
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
|
||||
busy.store(false, Ordering::Release);
|
||||
if slot.is_none() {
|
||||
@@ -113,7 +134,7 @@ impl Actor {
|
||||
thread: Mutex::new(Some(thread)),
|
||||
cancel,
|
||||
alive,
|
||||
crash: Arc::default(),
|
||||
crash,
|
||||
busy,
|
||||
last_used,
|
||||
epoch,
|
||||
@@ -148,7 +169,7 @@ impl Actor {
|
||||
return;
|
||||
}
|
||||
let Some(rt) = slot.as_mut() else { return };
|
||||
let outcome = f(rt);
|
||||
let outcome = catch_call(|| 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.
|
||||
@@ -174,21 +195,59 @@ 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| {
|
||||
if skip.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let Some(rt) = slot.as_mut() else { return };
|
||||
let outcome = f(rt);
|
||||
{
|
||||
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;
|
||||
}
|
||||
let outcome = catch_call(|| f(rt));
|
||||
*in_job.lock().unwrap_or_else(|e| e.into_inner()) = false;
|
||||
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);
|
||||
});
|
||||
{
|
||||
@@ -203,25 +262,34 @@ impl Actor {
|
||||
drop(outcome);
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
Ok(outcome) => return outcome.map_err(ActorError::Call),
|
||||
Ok(outcome) => return outcome,
|
||||
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());
|
||||
if self.is_busy() {
|
||||
self.cancel.cancel_soft();
|
||||
let running = running.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !*running {
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
self.cancel.cancel_soft();
|
||||
}
|
||||
Some(at) if at.elapsed() >= grace => {
|
||||
// Did not return in time: interrupt it (the instance is rebuilt).
|
||||
self.cancel();
|
||||
let running = running.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *running {
|
||||
self.cancel();
|
||||
}
|
||||
return Err(ActorError::Cancelled);
|
||||
}
|
||||
Some(_) => {}
|
||||
@@ -239,7 +307,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 = f(rt);
|
||||
let outcome = catch_call(|| 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);
|
||||
|
||||
@@ -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, read_text, resolve_in_root};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
|
||||
pub use types::*;
|
||||
pub use wasmtime::component::Component;
|
||||
Loaded 100 of 791 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user