mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30cc205aa4 | ||
|
|
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
+5
-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",
|
||||
@@ -5425,10 +5426,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 +6601,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -6773,12 +6771,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 +7228,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 +7512,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 +8329,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 +8593,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"whoami 2.1.3",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10566,12 +10535,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 +11109,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>
|
||||
+93
-21
@@ -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) =
|
||||
@@ -221,6 +238,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 +357,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 +478,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,
|
||||
@@ -605,6 +637,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 +646,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 +662,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();
|
||||
@@ -1301,11 +1340,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 +1358,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 +1408,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 +1434,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 +1458,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 +1472,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 +1486,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 +1500,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 +1536,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),
|
||||
}
|
||||
@@ -1579,11 +1647,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();
|
||||
}
|
||||
|
||||
@@ -406,6 +406,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -11,11 +11,24 @@ struct FakeAgent {
|
||||
calls: Mutex<Vec<String>>,
|
||||
/// 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 +100,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 +115,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 +139,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" => {
|
||||
@@ -250,6 +271,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();
|
||||
@@ -863,6 +927,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 +950,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 +974,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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ 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.
|
||||
@@ -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,6 +81,7 @@ 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(),
|
||||
@@ -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;
|
||||
@@ -315,6 +315,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());
|
||||
@@ -583,6 +588,16 @@ mod tests {
|
||||
|
||||
const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n";
|
||||
|
||||
#[test]
|
||||
fn write_capabilities_need_api_1_3() {
|
||||
for cap in ["project.write", "fs.write:/opt/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
|
||||
|
||||
@@ -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 {
|
||||
@@ -625,7 +621,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
mod build_tool_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -710,6 +706,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]
|
||||
|
||||
+202
-22
@@ -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).
|
||||
@@ -443,7 +449,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 +465,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 +492,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 +555,15 @@ 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 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).
|
||||
@@ -554,11 +614,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 +631,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.
|
||||
@@ -677,7 +759,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 +928,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 +945,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;
|
||||
@@ -1010,15 +1109,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 +1198,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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -149,25 +149,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.
|
||||
@@ -268,7 +299,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
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Largest storage of one plugin (keys + values).
|
||||
const MAX_STORAGE: usize = 1 << 20;
|
||||
const MAX_STORAGE: usize = 64 << 20;
|
||||
const MAX_KEY: usize = 256;
|
||||
/// Most status bar items per plugin.
|
||||
const MAX_STATUS_ITEMS: usize = 8;
|
||||
@@ -39,6 +39,11 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
fn projects(&self) -> Vec<PathBuf>;
|
||||
/// Unsaved text of the open document at `path`.
|
||||
fn buffer_text(&self, path: &Path) -> Option<String>;
|
||||
/// Whether the editor holds changes for `path` that are not on disk yet;
|
||||
/// writing such a file would lose them.
|
||||
fn buffer_dirty(&self, _path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
|
||||
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
|
||||
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
|
||||
@@ -188,7 +193,7 @@ pub(super) struct Shared {
|
||||
contexts: Mutex<BTreeMap<ContextKey, String>>,
|
||||
closed_clients: Mutex<BTreeSet<String>>,
|
||||
/// plugin → kinds subscribed at run time (kept across idle reclaim).
|
||||
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
|
||||
pub subscriptions: Mutex<super::event_paths::Subscriptions>,
|
||||
/// plugin → files it shows problems for, withdrawn when it stops.
|
||||
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
|
||||
pub secrets: RwLock<Arc<dyn SecretStore>>,
|
||||
@@ -196,6 +201,8 @@ pub(super) struct Shared {
|
||||
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// Validated UI declarations (settings schemas, declared views).
|
||||
pub ui: Arc<ide_extension::UiStore>,
|
||||
/// The UI language the windows report (BCP 47).
|
||||
pub locale: RwLock<String>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -207,6 +214,7 @@ impl Shared {
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ui,
|
||||
locale: RwLock::new("en".into()),
|
||||
sink,
|
||||
workspace: RwLock::new(None),
|
||||
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
|
||||
@@ -328,7 +336,7 @@ impl Shared {
|
||||
let _ = workspace.publish_diagnostics(plugin, &path, Vec::new());
|
||||
}
|
||||
}
|
||||
if subscriptions && self.subscriptions.lock().remove(plugin).is_some() {
|
||||
if subscriptions && self.subscriptions.lock().remove(plugin) {
|
||||
self.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
@@ -396,11 +404,62 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn storage_file(&self, plugin: &str) -> PathBuf {
|
||||
self.data_dir.join(plugin).join("storage.json")
|
||||
/// The plugin's stored values: one file per key, so a write only
|
||||
/// rewrites its own value.
|
||||
pub fn storage_dir(&self, plugin: &str) -> PathBuf {
|
||||
self.data_dir.join(plugin).join("storage")
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_io(e: std::io::Error) -> HostError {
|
||||
HostError::Failed(format!("storage: {e}"))
|
||||
}
|
||||
|
||||
/// A key's file name: its hash, since keys may hold any character.
|
||||
fn entry_name(key: &str) -> String {
|
||||
blake3::hash(key.as_bytes()).to_hex().to_string()
|
||||
}
|
||||
|
||||
/// A stored entry: the key's length (2 bytes, little-endian), the key, then the value.
|
||||
fn decode_entry(bytes: &[u8]) -> Option<(&str, &str)> {
|
||||
let len = usize::from(u16::from_le_bytes(bytes.get(..2)?.try_into().ok()?));
|
||||
let key = std::str::from_utf8(bytes.get(2..2 + len)?).ok()?;
|
||||
let value = std::str::from_utf8(&bytes[2 + len..]).ok()?;
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
|
||||
std::fs::create_dir_all(dir).map_err(storage_io)?;
|
||||
let file = dir.join(entry_name(key));
|
||||
let len = u16::try_from(key.len()).expect("keys are at most MAX_KEY bytes");
|
||||
let mut bytes = Vec::with_capacity(2 + key.len() + value.len());
|
||||
bytes.extend_from_slice(&len.to_le_bytes());
|
||||
bytes.extend_from_slice(key.as_bytes());
|
||||
bytes.extend_from_slice(value.as_bytes());
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
|
||||
}
|
||||
|
||||
/// The stored entries' files and the bytes of their keys and values.
|
||||
fn entries(dir: &Path) -> Result<impl Iterator<Item = (PathBuf, usize)>, HostError> {
|
||||
let list = match std::fs::read_dir(dir) {
|
||||
Ok(list) => list,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new().into_iter()),
|
||||
Err(e) => return Err(storage_io(e)),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in list {
|
||||
let entry = entry.map_err(storage_io)?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some() {
|
||||
continue; // a write that didn't finish
|
||||
}
|
||||
let size = entry.metadata().map_err(storage_io)?.len();
|
||||
out.push((path, usize::try_from(size).unwrap_or(usize::MAX).saturating_sub(2)));
|
||||
}
|
||||
Ok(out.into_iter())
|
||||
}
|
||||
|
||||
pub(super) struct PluginApi {
|
||||
pub pkg: Package,
|
||||
pub shared: Arc<Shared>,
|
||||
@@ -411,21 +470,24 @@ impl PluginApi {
|
||||
self.pkg.id()
|
||||
}
|
||||
|
||||
fn load_storage(&self) -> Result<BTreeMap<String, String>, HostError> {
|
||||
match std::fs::read(self.shared.storage_file(self.id())) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}"))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
|
||||
Err(e) => Err(HostError::Failed(format!("storage: {e}"))),
|
||||
/// The storage directory, after moving a `storage.json` written by an
|
||||
/// older version into it.
|
||||
fn storage(&self) -> Result<PathBuf, HostError> {
|
||||
let dir = self.shared.storage_dir(self.id());
|
||||
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
|
||||
match std::fs::read(&legacy) {
|
||||
Ok(bytes) => {
|
||||
let map: BTreeMap<String, String> =
|
||||
serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}")))?;
|
||||
for (key, value) in &map {
|
||||
write_entry(&dir, key, value)?;
|
||||
}
|
||||
std::fs::remove_file(&legacy).map_err(storage_io)?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(storage_io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_storage(&self, map: &BTreeMap<String, String>) -> Result<(), HostError> {
|
||||
let file = self.shared.storage_file(self.id());
|
||||
let bytes = serde_json::to_vec(map).map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
let io = |e: std::io::Error| HostError::Failed(format!("storage: {e}"));
|
||||
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(io)?;
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(io)
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The program approved for the (already checked) `process:` grant,
|
||||
@@ -658,10 +720,23 @@ fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
}
|
||||
|
||||
impl HostApi for PluginApi {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String) {
|
||||
self.shared
|
||||
.sink
|
||||
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
|
||||
fn notify(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
if !actions.is_empty() {
|
||||
let commands = self.shared.ui.items("commands", Some(self.id()));
|
||||
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
|
||||
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
|
||||
}
|
||||
}
|
||||
let plugin_id = self.id().to_owned();
|
||||
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
@@ -676,6 +751,14 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> {
|
||||
let client = from_window(client, "open a project")?;
|
||||
self.shared
|
||||
.sink
|
||||
.emit(Some(client), PluginEvent::OpenProject { plugin_id: self.id().to_owned(), path, new_window });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ask(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
@@ -839,30 +922,47 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
|
||||
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
|
||||
Ok(self.load_storage()?.get(key).cloned())
|
||||
let file = self.storage()?.join(entry_name(key));
|
||||
match std::fs::read(&file) {
|
||||
Ok(bytes) => Ok(decode_entry(&bytes).filter(|(k, _)| *k == key).map(|(_, v)| v.to_owned())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(storage_io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError> {
|
||||
if key.is_empty() || key.len() > MAX_KEY {
|
||||
return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes")));
|
||||
}
|
||||
let mut map = self.load_storage()?;
|
||||
let changed = match value {
|
||||
Some(v) => map.insert(key.to_owned(), v.clone()).as_ref() != Some(&v),
|
||||
None => map.remove(key).is_some(),
|
||||
let dir = self.storage()?;
|
||||
let file = dir.join(entry_name(key));
|
||||
let Some(value) = value else {
|
||||
return match std::fs::remove_file(&file) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(storage_io(e)),
|
||||
_ => Ok(()),
|
||||
};
|
||||
};
|
||||
if !changed {
|
||||
return Ok(());
|
||||
let others: usize = entries(&dir)?.filter(|(name, _)| *name != file).map(|(_, size)| size).sum();
|
||||
if others + key.len() + value.len() > MAX_STORAGE {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"storage is limited to {} MiB (UTF-8 keys and values)",
|
||||
MAX_STORAGE >> 20
|
||||
)));
|
||||
}
|
||||
let size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum();
|
||||
if size > MAX_STORAGE {
|
||||
return Err(HostError::Invalid(format!("storage is limited to {} KiB", MAX_STORAGE >> 10)));
|
||||
}
|
||||
self.save_storage(&map)
|
||||
write_entry(&dir, key, &value)
|
||||
}
|
||||
|
||||
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
|
||||
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
|
||||
let dir = self.storage()?;
|
||||
let mut keys = Vec::new();
|
||||
for (file, _) in entries(&dir)? {
|
||||
let bytes = std::fs::read(&file).map_err(storage_io)?;
|
||||
if let Some((key, _)) = decode_entry(&bytes).filter(|(k, _)| k.starts_with(prefix)) {
|
||||
keys.push(key.to_owned());
|
||||
}
|
||||
}
|
||||
keys.sort();
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
|
||||
@@ -1071,13 +1171,22 @@ impl HostApi for PluginApi {
|
||||
})
|
||||
}
|
||||
|
||||
fn subscribe(&self, kind: EventKind, on: bool) {
|
||||
let changed = {
|
||||
let mut subs = self.shared.subscriptions.lock();
|
||||
let set = subs.entry(self.id().to_owned()).or_default();
|
||||
if on { set.insert(kind) } else { set.remove(&kind) }
|
||||
};
|
||||
if changed {
|
||||
fn subscribe(&self, kind: EventKind) {
|
||||
if self.shared.subscriptions.lock().set(self.id(), kind) {
|
||||
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError> {
|
||||
let filter = super::event_paths::PathFilter::new(kind, globs)?;
|
||||
if self.shared.subscriptions.lock().set_paths(self.id(), kind, filter) {
|
||||
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unsubscribe(&self, kind: &str) {
|
||||
if self.shared.subscriptions.lock().unsubscribe(self.id(), kind) {
|
||||
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
@@ -1167,6 +1276,10 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn locale(&self) -> String {
|
||||
self.shared.locale.read().clone()
|
||||
}
|
||||
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
|
||||
let schema: Vec<super::ui_points::SettingSchema> = self
|
||||
.shared
|
||||
@@ -1179,6 +1292,81 @@ impl HostApi for PluginApi {
|
||||
let value = user.get(key).filter(|v| setting.accepts(v)).cloned().unwrap_or(setting.default);
|
||||
serde_json::to_string(&value).ok()
|
||||
}
|
||||
|
||||
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError> {
|
||||
// Unsaved editor contents win, as they do for project files.
|
||||
if let Some(text) = self.shared.workspace().and_then(|w| w.buffer_text(real)) {
|
||||
return Ok(text);
|
||||
}
|
||||
let meta = std::fs::metadata(real).map_err(|e| not_a_file(real, e))?;
|
||||
if !meta.is_file() {
|
||||
return Err(HostError::Invalid(format!("{}: not a file", real.display())));
|
||||
}
|
||||
if meta.len() > max as u64 {
|
||||
return Err(too_large(real, max));
|
||||
}
|
||||
let loaded = ide_vfs::load_text(real).map_err(failed)?;
|
||||
if loaded.binary {
|
||||
return Err(HostError::Invalid(format!("{}: not text; read it as bytes", real.display())));
|
||||
}
|
||||
Ok(loaded.text)
|
||||
}
|
||||
|
||||
fn write_text_file(&self, real: &Path, text: &str) -> Result<(), HostError> {
|
||||
check_unsaved(self.shared.workspace(), real)?;
|
||||
// Write back in the file's own encoding, so a GBK properties file stays
|
||||
// one; a file that is not there yet becomes UTF-8.
|
||||
let (encoding, has_bom) = match ide_vfs::load_text(real) {
|
||||
Ok(loaded) if loaded.binary => {
|
||||
return Err(HostError::Invalid(format!("{}: not text; write it as bytes", real.display())));
|
||||
}
|
||||
Ok(loaded) => (loaded.encoding, loaded.has_bom),
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
|
||||
Err(e) => return Err(not_writable(real, e)),
|
||||
};
|
||||
ide_vfs::save_text(real, text, &encoding, has_bom).map(drop).map_err(|e| not_writable(real, e))
|
||||
}
|
||||
|
||||
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
|
||||
check_unsaved(self.shared.workspace(), real)?;
|
||||
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
}
|
||||
}
|
||||
|
||||
/// A file that is not there, or cannot be read, as the plugin sees it.
|
||||
fn not_a_file(path: &Path, e: std::io::Error) -> HostError {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => HostError::NotFound(format!("{}: no such file", path.display())),
|
||||
std::io::ErrorKind::PermissionDenied => HostError::Denied(format!("{}: {}", path.display(), "not readable")),
|
||||
_ => failed(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn too_large(path: &Path, max: usize) -> HostError {
|
||||
HostError::Invalid(format!("{}: file is larger than {} MiB", path.display(), max >> 20))
|
||||
}
|
||||
|
||||
/// Refuses to replace a file the editor holds unsaved changes for: the write
|
||||
/// would lose them.
|
||||
fn check_unsaved(workspace: Option<Arc<dyn PluginWorkspace>>, path: &Path) -> Result<(), HostError> {
|
||||
match workspace {
|
||||
Some(workspace) if workspace.buffer_dirty(path) => Err(HostError::Invalid(format!(
|
||||
"{}: the editor has unsaved changes for it; save them first",
|
||||
path.display()
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A write that did not land, as the plugin sees it.
|
||||
fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
|
||||
let message = format!("{}: {}", path.display(), e.message);
|
||||
match e.kind {
|
||||
ide_core::error::ErrorKind::NotFound => HostError::NotFound(message),
|
||||
ide_core::error::ErrorKind::PermissionDenied => HostError::Denied(message),
|
||||
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(message),
|
||||
_ => HostError::Failed(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`PluginWorkspace`] over the desktop host's open projects.
|
||||
@@ -1207,6 +1395,11 @@ impl PluginWorkspace for HostWorkspace {
|
||||
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
|
||||
}
|
||||
|
||||
fn buffer_dirty(&self, path: &Path) -> bool {
|
||||
let Some((project, doc)) = self.open_document(path) else { return false };
|
||||
project.documents.is_dirty(doc).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
// The index of the project rooted at `root` (a workspace member's files are in its own).
|
||||
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
|
||||
@@ -1292,6 +1485,78 @@ mod tests {
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_moves_an_older_storage_json_to_one_file_per_key() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let legacy = tmp.path().join(".data/acme.lint/storage.json");
|
||||
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
|
||||
std::fs::write(&legacy, r#"{"a":"1","b/c":"two"}"#).unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
assert_eq!(api.storage_keys("").unwrap(), ["a", "b/c"]);
|
||||
assert_eq!(api.storage_get("b/c").unwrap().as_deref(), Some("two"));
|
||||
assert!(!legacy.exists());
|
||||
api.storage_set("a", Some("3".into())).unwrap();
|
||||
api.storage_set("b/c", None).unwrap();
|
||||
assert_eq!(api.storage_keys("").unwrap(), ["a"]);
|
||||
assert_eq!(api.storage_get("a").unwrap().as_deref(), Some("3"));
|
||||
assert_eq!(api.storage_get("b/c").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_keeps_multimegabyte_indexes_after_host_recreation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let value = "é".repeat(2 << 20);
|
||||
{
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
api.storage_set("index", Some(value.clone())).unwrap();
|
||||
}
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
assert_eq!(api.storage_get("index").unwrap().as_deref(), Some(value.as_str()));
|
||||
api.storage_set("index", None).unwrap();
|
||||
assert_eq!(api.storage_get("index").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_quota_counts_utf8_keys_and_values_and_preserves_data_on_failure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let key = "index";
|
||||
let mut value = "é".repeat(((64 << 20) - key.len()) / 2);
|
||||
value.push('x');
|
||||
api.storage_set(key, Some(value.clone())).unwrap();
|
||||
|
||||
for (key, value) in [("x", String::new()), (key, format!("{value}x"))] {
|
||||
let err = api.storage_set(key, Some(value)).unwrap_err();
|
||||
assert!(matches!(err, HostError::Invalid(ref message) if message.contains("64 MiB")), "{err}");
|
||||
}
|
||||
assert_eq!(api.storage_keys("").unwrap(), [key]);
|
||||
assert_eq!(api.storage_get(key).unwrap().as_deref(), Some(value.as_str()));
|
||||
api.storage_set(key, Some("small".into())).unwrap();
|
||||
api.storage_set("other", Some("value".into())).unwrap();
|
||||
assert_eq!(api.storage_get("other").unwrap().as_deref(), Some("value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_actions_run_the_plugins_own_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let _command = api.shared.ui.insert(ide_extension::UiContribution {
|
||||
plugin_id: api.id().into(),
|
||||
point: "commands".into(),
|
||||
key: "run".into(),
|
||||
decl: serde_json::json!({}),
|
||||
});
|
||||
let action = |command: &str| ide_plugin::NotificationAction {
|
||||
label: "Run".into(),
|
||||
command: command.into(),
|
||||
argument: Some("a\nb".into()),
|
||||
};
|
||||
let notify = |actions| api.notify(Some("main"), NotificationLevel::Info, "Done".into(), "".into(), actions);
|
||||
notify(vec![]).unwrap();
|
||||
notify(vec![action("run")]).unwrap();
|
||||
assert!(matches!(notify(vec![action("run"), action("other.command")]), Err(HostError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_and_search_paths_match_ipc_events() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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,176 @@
|
||||
//! 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") {
|
||||
return Err(HostError::Invalid("subscribe-paths supports file-opened and file-saved".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(
|
||||
&self,
|
||||
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 = self.shared.workspace();
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
let path = match event {
|
||||
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
|
||||
_ => return true,
|
||||
};
|
||||
let Some(filter) = filters.get(event.kind().name()) else { return true };
|
||||
if !path.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let Some(workspace) = &workspace else { return false };
|
||||
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())
|
||||
}
|
||||
};
|
||||
let Some(root) = root else { return false };
|
||||
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
|
||||
return false;
|
||||
}
|
||||
filter.matches(&root, path)
|
||||
})
|
||||
.cloned()
|
||||
.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());
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,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));
|
||||
}
|
||||
}
|
||||
@@ -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,15 @@
|
||||
|
||||
mod api;
|
||||
pub mod commit;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
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 +47,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,
|
||||
@@ -321,6 +325,7 @@ pub struct PluginHost {
|
||||
/// `[[contributes.providers]]` of enabled plugins.
|
||||
providers: Arc<providers::Providers>,
|
||||
navigation: Mutex<navigation::NavigationIndex>,
|
||||
endpoints: Mutex<endpoints::Endpoints>,
|
||||
/// Validated UI declarations of installed packages (start-up reuses them).
|
||||
index: Arc<ContributionIndex>,
|
||||
ledger: Mutex<Ledger>,
|
||||
@@ -410,6 +415,7 @@ impl PluginHost {
|
||||
run_detectors,
|
||||
providers,
|
||||
navigation: Mutex::default(),
|
||||
endpoints: Mutex::default(),
|
||||
index,
|
||||
plugins: RwLock::default(),
|
||||
state: Mutex::new(state),
|
||||
@@ -698,6 +704,15 @@ 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)?;
|
||||
@@ -902,6 +917,7 @@ impl PluginHost {
|
||||
level: ide_plugin::NotificationLevel::Warning,
|
||||
title: format!("{name} update rolled back"),
|
||||
body: message.clone(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
self.changed();
|
||||
@@ -1435,7 +1451,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);
|
||||
}
|
||||
}
|
||||
@@ -1468,7 +1484,8 @@ impl PluginHost {
|
||||
let mut queues = self.queues.lock();
|
||||
for p in plugins {
|
||||
let wanted: Vec<WorkbenchEvent> =
|
||||
events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect();
|
||||
events.iter().filter(|e| self.wants(&p, e.kind(), subs.kinds.get(p.id()))).cloned().collect();
|
||||
let wanted = self.filter_path_events(&p, client, &wanted, &subs);
|
||||
if wanted.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -1548,6 +1565,18 @@ impl PluginHost {
|
||||
/// Returns whether the plugin was activated.
|
||||
fn deliver(&self, id: &str, client: Option<String>, events: Vec<WorkbenchEvent>) -> bool {
|
||||
let Ok(plugin) = self.get(id) else { return false };
|
||||
let subs = self.shared.subscriptions.lock().clone();
|
||||
let events: Vec<_> = self
|
||||
.filter_path_events(&plugin, client.as_deref(), &events, &subs)
|
||||
.into_iter()
|
||||
.filter(|event| {
|
||||
!matches!(event, WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_))
|
||||
|| self.wants(&plugin, event.kind(), subs.kinds.get(id))
|
||||
})
|
||||
.collect();
|
||||
if events.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !plugin.meta.lock().pending.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -2073,6 +2102,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();
|
||||
@@ -2118,6 +2148,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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2145,6 +2176,7 @@ impl PluginHost {
|
||||
|
||||
fn stop(&self, plugin: &LoadedPlugin) {
|
||||
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() {
|
||||
@@ -2157,6 +2189,7 @@ impl PluginHost {
|
||||
|
||||
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());
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -2166,6 +2199,7 @@ impl PluginHost {
|
||||
|
||||
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 +2213,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();
|
||||
|
||||
@@ -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)))?;
|
||||
@@ -69,5 +70,6 @@ pub fn register_builtin_points(
|
||||
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(())
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub enum ProviderKind {
|
||||
LineMarkers,
|
||||
SemanticTokens,
|
||||
Hover,
|
||||
InlayHints,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -120,6 +121,9 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
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 +169,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};
|
||||
@@ -319,6 +323,12 @@ impl PluginHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
@@ -357,6 +367,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.
|
||||
|
||||
@@ -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
|
||||
@@ -119,10 +119,12 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
| PluginEvent::OpenProject { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
| PluginEvent::CommitRequested { .. }
|
||||
| PluginEvent::EndpointsChanged { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -384,6 +386,14 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
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 +568,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> },
|
||||
@@ -222,6 +233,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";
|
||||
@@ -122,6 +122,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 +140,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)) => {
|
||||
@@ -588,6 +599,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 +681,41 @@ 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());
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,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 +214,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,
|
||||
@@ -347,6 +354,9 @@ 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());
|
||||
}
|
||||
// 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).
|
||||
|
||||
@@ -300,6 +300,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"
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
|
||||
/// What plugins see of the test project: real `Documents`, as in the IDE.
|
||||
struct TestWorkspace {
|
||||
root: PathBuf,
|
||||
other_roots: Mutex<Vec<PathBuf>>,
|
||||
documents: Arc<ide_workspace::Documents>,
|
||||
trusted: bool,
|
||||
/// (producer, path) → published diagnostics.
|
||||
@@ -35,6 +36,7 @@ impl TestWorkspace {
|
||||
);
|
||||
Arc::new(Self {
|
||||
root: root.to_path_buf(),
|
||||
other_roots: Mutex::default(),
|
||||
documents: Arc::new(documents),
|
||||
trusted: true,
|
||||
diagnostics: Mutex::default(),
|
||||
@@ -67,11 +69,14 @@ impl TestWorkspace {
|
||||
|
||||
impl PluginWorkspace for TestWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.root.clone()]
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
self.documents.text_of(path)
|
||||
}
|
||||
fn buffer_dirty(&self, path: &Path) -> bool {
|
||||
self.documents.by_path(path).is_some_and(|doc| self.documents.is_dirty(doc).unwrap_or(false))
|
||||
}
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
let snap = self.documents.snapshot(self.documents.by_path(path)?).ok()?;
|
||||
Some((u64::from(snap.version), snap.text))
|
||||
@@ -124,6 +129,33 @@ fn misbehave_pkg() -> PathBuf {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
|
||||
}
|
||||
|
||||
/// `misbehave` with `fs.read:<granted>` added to its capabilities.
|
||||
fn misbehave_granted(dir: &Path, granted: &Path) -> PathBuf {
|
||||
let pkg = dir.join("misbehave-granted");
|
||||
copy_dir(&misbehave_pkg(), &pkg);
|
||||
let manifest = pkg.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap();
|
||||
let path = granted.to_string_lossy().replace('\\', "/");
|
||||
let required = format!("required = [\"project.read\", \"fs.read:{path}\"]");
|
||||
let text = text.replace("required = [\"project.read\"]", &required);
|
||||
std::fs::write(&manifest, text).unwrap();
|
||||
pkg
|
||||
}
|
||||
|
||||
/// `misbehave` requiring `capability` too, which is new in API 1.3.
|
||||
fn misbehave_with(dir: &Path, name: &str, capability: &str) -> PathBuf {
|
||||
let pkg = dir.join(name);
|
||||
copy_dir(&misbehave_pkg(), &pkg);
|
||||
let manifest = pkg.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap();
|
||||
let required = format!("required = [\"project.read\", \"{capability}\"]");
|
||||
let text = text
|
||||
.replace("required = [\"project.read\"]", &required)
|
||||
.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"");
|
||||
std::fs::write(&manifest, text).unwrap();
|
||||
pkg
|
||||
}
|
||||
|
||||
/// Whether `f` holds within 5 s. A call a slow runner keeps waiting behind
|
||||
/// another (or behind a new instance starting) holds once it caught up.
|
||||
fn eventually(mut f: impl FnMut() -> bool) -> bool {
|
||||
@@ -361,6 +393,9 @@ fn read_file_is_confined_to_the_project() {
|
||||
let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
|
||||
// A missing path inside the project is `not-found`, like the mock reports.
|
||||
let err = f.run("misbehave", "read", Some("src/missing.txt")).unwrap_err();
|
||||
assert!(err.contains("not found"), "{err}");
|
||||
// Without a project there is nothing to read.
|
||||
let inv = Invocation::default();
|
||||
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
|
||||
@@ -369,6 +404,153 @@ fn read_file_is_confined_to_the_project() {
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `fs.read` reaches the granted paths and nothing else, through the real
|
||||
/// component: the same checks the runtime makes, against a real file system.
|
||||
#[test]
|
||||
fn fs_read_reaches_only_the_granted_paths() {
|
||||
let f = fixture(Limits::default());
|
||||
let shared = f._tmp.path().join("shared");
|
||||
std::fs::create_dir_all(&shared).unwrap();
|
||||
let notes = shared.join("notes.txt");
|
||||
std::fs::write(¬es, "outside the project\n").unwrap();
|
||||
// A sibling whose name starts with the grant's: not covered by it.
|
||||
let sibling = f._tmp.path().join("shared-other");
|
||||
std::fs::create_dir_all(&sibling).unwrap();
|
||||
let other = sibling.join("notes.txt");
|
||||
std::fs::write(&other, "not the same directory\n").unwrap();
|
||||
let secret = f._tmp.path().join("secret.txt");
|
||||
f.install(&misbehave_granted(f._tmp.path(), &shared));
|
||||
|
||||
assert_eq!(f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap().unwrap(), "outside the project\n");
|
||||
let err = f.run("misbehave", "fsread", Some(secret.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.read:"), "the refusal names what to grant: {err}");
|
||||
let err = f.run("misbehave", "fsread", Some(other.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.read:"), "a name prefix grants nothing: {err}");
|
||||
let err = f.run("misbehave", "fsread", Some("notes.txt")).unwrap_err();
|
||||
assert!(err.contains("absolute"), "{err}");
|
||||
let err = f.run("misbehave", "fsread", Some(shared.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("not a file"), "{err}");
|
||||
let missing = shared.join("gone.txt");
|
||||
let err = f.run("misbehave", "fsread", Some(missing.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("no such file"), "{err}");
|
||||
// A refusal is the plugin's to handle, like any other error it gets.
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
/// `fs.read-dir` lists a granted directory, sorted, and refuses the rest.
|
||||
#[test]
|
||||
fn fs_read_dir_lists_a_granted_directory() {
|
||||
let f = fixture(Limits::default());
|
||||
let shared = f._tmp.path().join("shared");
|
||||
std::fs::create_dir_all(shared.join("zeta")).unwrap();
|
||||
std::fs::write(shared.join("beta.txt"), "b").unwrap();
|
||||
std::fs::write(shared.join("alpha.txt"), "a").unwrap();
|
||||
f.install(&misbehave_granted(f._tmp.path(), &shared));
|
||||
|
||||
let listing = f.run("misbehave", "read-dir", Some(shared.to_str().unwrap())).unwrap().unwrap();
|
||||
let lines: Vec<&str> = listing.lines().collect();
|
||||
assert_eq!(lines, vec!["file alpha.txt", "file beta.txt", "dir zeta"], "{listing}");
|
||||
|
||||
// A file is not a directory, and a path outside the grant is refused.
|
||||
let file = shared.join("alpha.txt");
|
||||
let err = f.run("misbehave", "read-dir", Some(file.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("not a directory"), "{err}");
|
||||
let err = f.run("misbehave", "read-dir", Some(f._tmp.path().to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.read:"), "{err}");
|
||||
}
|
||||
|
||||
/// `project.write` creates and replaces files under the project root, and never
|
||||
/// leaves it, through the real component.
|
||||
#[test]
|
||||
fn project_write_stays_inside_the_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
|
||||
|
||||
// A Run configuration: neither the file nor `.openidea/` exists yet.
|
||||
assert_eq!(f.run("misbehave", "projwrite", Some(".openidea/run.toml")).unwrap(), None);
|
||||
let conf = f.project.join(".openidea").join("run.toml");
|
||||
assert_eq!(std::fs::read_to_string(&conf).unwrap(), "written inside the project\n");
|
||||
// What it wrote reads back through the project.
|
||||
assert_eq!(
|
||||
f.run("misbehave", "read", Some(".openidea/run.toml")).unwrap().as_deref(),
|
||||
Some("written inside the project\n")
|
||||
);
|
||||
// An absolute path inside the root lands in the same place.
|
||||
let fresh = f.project.join("build").join("patch.txt");
|
||||
assert_eq!(f.run("misbehave", "projwrite", Some(fresh.to_str().unwrap())).unwrap(), None);
|
||||
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written inside the project\n");
|
||||
// Replacing an existing file rewrites it whole.
|
||||
assert_eq!(f.run("misbehave", "projwrite", Some("src/notes.txt")).unwrap(), None);
|
||||
assert_eq!(std::fs::read_to_string(f.project.join("src/notes.txt")).unwrap(), "written inside the project\n");
|
||||
// Unsaved changes in an editor are not overwritten.
|
||||
f.workspace.open("src/notes.txt", &[]);
|
||||
f.workspace.type_text("src/notes.txt", "typed ");
|
||||
let err = f.run("misbehave", "projwrite", Some("src/notes.txt")).unwrap_err();
|
||||
assert!(err.contains("unsaved changes"), "{err}");
|
||||
// Leaving the root is refused, and nothing is written.
|
||||
let err = f.run("misbehave", "projwrite", Some("../escape.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
/// `fs.write` replaces files the grant covers and nothing else, through the
|
||||
/// real component.
|
||||
#[test]
|
||||
fn fs_write_reaches_only_the_granted_paths() {
|
||||
let f = fixture(Limits::default());
|
||||
let shared = f._tmp.path().join("shared");
|
||||
std::fs::create_dir_all(&shared).unwrap();
|
||||
let notes = shared.join("notes.txt");
|
||||
std::fs::write(¬es, "old\n").unwrap();
|
||||
let bommed = shared.join("bom.txt");
|
||||
std::fs::write(&bommed, b"\xef\xbb\xbfold").unwrap();
|
||||
let sibling = f._tmp.path().join("shared-other");
|
||||
std::fs::create_dir_all(&sibling).unwrap();
|
||||
let other = sibling.join("notes.txt");
|
||||
std::fs::write(&other, "not the same directory\n").unwrap();
|
||||
let secret = f._tmp.path().join("secret.txt");
|
||||
std::fs::write(&secret, "keep me\n").unwrap();
|
||||
let grant = format!("fs.write:{}", shared.to_string_lossy().replace('\\', "/"));
|
||||
f.install(&misbehave_with(f._tmp.path(), "misbehave-writable", &grant));
|
||||
|
||||
// An existing file is replaced…
|
||||
assert_eq!(f.run("misbehave", "fswrite", Some(notes.to_str().unwrap())).unwrap(), None);
|
||||
assert_eq!(std::fs::read_to_string(¬es).unwrap(), "written by the plugin\n");
|
||||
// …a new one is created…
|
||||
let fresh = shared.join("fresh.txt");
|
||||
assert_eq!(f.run("misbehave", "fswrite", Some(fresh.to_str().unwrap())).unwrap(), None);
|
||||
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written by the plugin\n");
|
||||
// …and a file's encoding and BOM survive the write.
|
||||
assert_eq!(f.run("misbehave", "fswrite", Some(bommed.to_str().unwrap())).unwrap(), None);
|
||||
let bytes = std::fs::read(&bommed).unwrap();
|
||||
assert!(bytes.starts_with(b"\xef\xbb\xbf"), "the BOM stays: {bytes:?}");
|
||||
assert_eq!(String::from_utf8(bytes[3..].to_vec()).unwrap(), "written by the plugin\n");
|
||||
|
||||
// Outside the grant nothing changes.
|
||||
let err = f.run("misbehave", "fswrite", Some(secret.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.write:"), "the refusal names what to grant: {err}");
|
||||
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "keep me\n");
|
||||
let err = f.run("misbehave", "fswrite", Some(other.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.write:"), "a name prefix grants nothing: {err}");
|
||||
let err = f.run("misbehave", "fswrite", Some("notes.txt")).unwrap_err();
|
||||
assert!(err.contains("absolute"), "{err}");
|
||||
let missing_parent = shared.join("conf").join("web.xml");
|
||||
let err = f.run("misbehave", "fswrite", Some(missing_parent.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("directory does not exist"), "{err}");
|
||||
// Writing a directory fails rather than replacing it.
|
||||
assert!(f.run("misbehave", "fswrite", Some(shared.to_str().unwrap())).is_err(), "a directory is not a file");
|
||||
assert!(shared.is_dir(), "the directory is still there");
|
||||
|
||||
// `fs.write` does not carry `fs.read`.
|
||||
let err = f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap_err();
|
||||
assert!(err.contains("fs.read:"), "write does not imply read: {err}");
|
||||
// A refusal is the plugin's to handle, like any other error it gets.
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
|
||||
}
|
||||
|
||||
/// A missing capability is an error the plugin can handle, not a trap.
|
||||
#[test]
|
||||
fn ungranted_capabilities_are_denied_not_trapped() {
|
||||
@@ -645,7 +827,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
|
||||
assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay");
|
||||
assert_eq!(f.command_titles("misbehave").len(), 14, "contributions stay");
|
||||
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
|
||||
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
|
||||
|
||||
@@ -1528,8 +1710,8 @@ fn official_key_promoter() {
|
||||
assert_eq!(tips.len(), 1, "{tips:?}");
|
||||
assert_eq!(tips[0].0.as_deref(), Some("main"), "shown in the window the actions came from");
|
||||
assert!(tips[0].1.contains("Ctrl+D") && tips[0].2.contains("3 times"), "{tips:?}");
|
||||
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.key-promoter/storage.json")).unwrap();
|
||||
assert!(stored.contains("\"count:code.duplicate\":\"3\""), "{stored}");
|
||||
let stored = stored(&f, "openidea.key-promoter");
|
||||
assert!(stored.contains(&("count:code.duplicate".into(), "3".into())), "{stored:?}");
|
||||
|
||||
// Uninstalling removes its data.
|
||||
f.host.uninstall("openidea.key-promoter").unwrap();
|
||||
@@ -1537,6 +1719,43 @@ fn official_key_promoter() {
|
||||
assert!(f.host.event_kinds().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_keeps_multimegabyte_indexes_after_idle_reclaim_and_removes_them_on_uninstall() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
|
||||
let pkg = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/storage");
|
||||
f.install(&pkg);
|
||||
f.run("test.storage", "save", None).unwrap();
|
||||
assert_eq!(f.host.instances(), 1);
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
f.host.tick();
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
assert_eq!(f.host.info_of("test.storage").unwrap().stats.reclaims, 1);
|
||||
assert_eq!(f.run("test.storage", "load", None).unwrap().as_deref(), Some("4194304"));
|
||||
assert_eq!(f.host.info_of("test.storage").unwrap().stats.activations, 2);
|
||||
let storage_dir = f.plugins_dir.join(".data/test.storage");
|
||||
assert_eq!(std::fs::read_dir(storage_dir.join("storage")).unwrap().count(), 1, "one file per key");
|
||||
f.host.uninstall("test.storage").unwrap();
|
||||
assert!(!storage_dir.exists());
|
||||
f.install(&pkg);
|
||||
assert!(f.run("test.storage", "load", None).unwrap_err().contains("missing index"));
|
||||
}
|
||||
|
||||
/// A plugin's stored entries (key, value), read from its files.
|
||||
fn stored(f: &Fixture, plugin: &str) -> Vec<(String, String)> {
|
||||
let dir = f.plugins_dir.join(".data").join(plugin).join("storage");
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|e| {
|
||||
let bytes = std::fs::read(e.unwrap().path()).unwrap();
|
||||
let len = usize::from(u16::from_le_bytes([bytes[0], bytes[1]]));
|
||||
let key = String::from_utf8(bytes[2..2 + len].to_vec()).unwrap();
|
||||
(key, String::from_utf8(bytes[2 + len..].to_vec()).unwrap())
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
entries
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
|
||||
@@ -1601,8 +1820,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
)
|
||||
);
|
||||
assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. })));
|
||||
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.git-toolbox/storage.json")).unwrap();
|
||||
assert!(stored.contains("last-fetch:"), "{stored}");
|
||||
let stored = stored(&f, "openidea.git-toolbox");
|
||||
assert!(stored.iter().any(|(key, _)| key.starts_with("last-fetch:")), "{stored:?}");
|
||||
}
|
||||
|
||||
/// Restricted Mode: git does not run in an untrusted project (no fetch).
|
||||
@@ -1613,6 +1832,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
f.install(&official("git-toolbox"));
|
||||
let untrusted = Arc::new(TestWorkspace {
|
||||
root: f.project.clone(),
|
||||
other_roots: Mutex::default(),
|
||||
documents: f.workspace.documents.clone(),
|
||||
trusted: false,
|
||||
diagnostics: Mutex::default(),
|
||||
@@ -1940,6 +2160,35 @@ fn official_csv_editor_edits_the_shared_document() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_entries_can_name_a_submenu() {
|
||||
fn with_menu(version: &'static str, extra: &'static str) -> impl Fn(&str) -> String {
|
||||
move |m: &str| {
|
||||
let m = m.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{version}\""));
|
||||
format!("{m}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n")
|
||||
}
|
||||
}
|
||||
let f = fixture(Limits::default());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ok = hello_variant(dir.path(), with_menu("^1.3", "menu = \"editor.context\"\nsubmenu = \"Words\""));
|
||||
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
|
||||
let submenus: Vec<_> =
|
||||
f.host.ui_items("menus").into_iter().filter_map(|i| i.decl["submenu"].as_str().map(str::to_owned)).collect();
|
||||
assert_eq!(submenus, ["Words"]);
|
||||
f.host.uninstall("hello").unwrap();
|
||||
|
||||
for (version, extra, message) in [
|
||||
("^1.3", "menu = \"tools\"\nsubmenu = \"Words\"", "only for context menus"),
|
||||
("^1.3", "menu = \"editor.context\"\nsubmenu = \" \"", "titles are"),
|
||||
("^1.2", "menu = \"editor.context\"\nsubmenu = \"Words\"", "1.3"),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(dir.path(), with_menu(version, extra));
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains(message), "{extra}: {}", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_item_menus_name_a_declared_view() {
|
||||
/// The menu is new in API 1.2.
|
||||
@@ -2045,6 +2294,45 @@ fn sample_plugin_tool_window_with_a_tree() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tables fixture sorts its own rows: a `table` body sent through `other`
|
||||
/// opts the headers in, and a header click leaves as an `other` view-event
|
||||
/// (kind `sort`) the plugin decodes and answers with the rows in the asked
|
||||
/// order (plugins/README.md §10: released types stay, `other` carries new
|
||||
/// cases).
|
||||
#[test]
|
||||
fn sortable_plugin_table_round_trips_sort_events() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("tables"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
|
||||
let table = |f: &Fixture| match f.host.render_view("tables", "tables", None, f.inv()).unwrap().body {
|
||||
ide_plugin::ViewBody::Table(t) => t,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
// No sort yet: headers are clickable, rows in the plugin's own order.
|
||||
let t = table(&f);
|
||||
assert_eq!((t.columns.clone(), t.total_rows), (vec!["ext".to_owned(), "files".into()], 4));
|
||||
assert_eq!((t.sortable, t.sort), (true, None), "a `table` through `other` opts the headers in");
|
||||
assert_eq!(t.rows.iter().map(|r| r[0].clone()).collect::<Vec<_>>(), ["rs", "md", "java", "toml"]);
|
||||
|
||||
f.take_events();
|
||||
let sort = |f: &Fixture, descending| {
|
||||
f.host.view_event("tables", "tables", ide_plugin::ViewEvent::Sort { column: 1, descending }, f.inv()).unwrap();
|
||||
assert!(f.take_events().into_iter().any(
|
||||
|(_, e)| matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "tables" && view == "tables")
|
||||
));
|
||||
table(f)
|
||||
};
|
||||
|
||||
// The plugin reports the sort it ordered by, with the rows to match.
|
||||
let t = sort(&f, false);
|
||||
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: false }));
|
||||
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["3", "9", "12", "20"]);
|
||||
let t = sort(&f, true);
|
||||
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: true }));
|
||||
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["20", "12", "9", "3"]);
|
||||
}
|
||||
|
||||
/// Regex Tester: an `html` body its script talks to; the plugin answers in
|
||||
/// view messages, in UTF-16 offsets.
|
||||
#[test]
|
||||
@@ -2270,9 +2558,14 @@ fn commands_ask_the_user_and_reveal_their_views() {
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("Other"));
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) });
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()), multiline: false });
|
||||
assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none"));
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "input-multiline", Some("one\ntwo"), Some(&["a\nb"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("a\nb"));
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("one\ntwo".into()), multiline: true });
|
||||
assert_eq!(run_and_answer(&f, "input-multiline", None, None, none).0.unwrap().as_deref(), Some("none"));
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("true"));
|
||||
assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() });
|
||||
@@ -2291,7 +2584,7 @@ fn commands_ask_the_user_and_reveal_their_views() {
|
||||
// Without a window there is nothing to attach the dialog to.
|
||||
f.take_events();
|
||||
let no_window = Invocation { client: None, ..f.inv() };
|
||||
for cmd in ["pick", "input", "confirm"] {
|
||||
for cmd in ["pick", "input", "input-multiline", "confirm"] {
|
||||
let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message;
|
||||
assert!(err.contains("from a command the user invoked"), "{cmd}: {err}");
|
||||
}
|
||||
@@ -2379,6 +2672,46 @@ web = "panel/index.html"
|
||||
assert!(assets[0].1.contains("<rect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_ships_translations_in_locales() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let package = |id: &str, catalogs: &[(&str, &str)]| {
|
||||
let pkg = tmp.path().join(id);
|
||||
std::fs::create_dir_all(pkg.join("locales")).unwrap();
|
||||
for (name, text) in catalogs {
|
||||
std::fs::write(pkg.join("locales").join(name), text).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
"id = \"{id}\"\nname = \"Words\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\n\
|
||||
[[contributes.commands]]\nid = \"count\"\ntitle = \"Count Words\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
pkg
|
||||
};
|
||||
|
||||
for bad in ["{", "[]", r#"{"Count Words": 1}"#] {
|
||||
let pkg = package("bad-catalog", &[("fr.json", bad)]);
|
||||
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "{bad}");
|
||||
}
|
||||
|
||||
let pkg = package("words", &[("fr.json", r#"{"Count Words": "Compter les mots"}"#), ("zh-CN.json", "{}")]);
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let item = f.host.ui_items("locales").into_iter().find(|i| i.plugin_id == "words").expect("the catalogs");
|
||||
assert_eq!(item.decl["tags"], serde_json::json!(["fr", "zh-CN"]));
|
||||
let assets = f.host.assets("words", "locale/fr").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("Compter les mots"));
|
||||
|
||||
let other = f.install(&package("other", &[]));
|
||||
assert!(other.error.is_none(), "{:?}", other.error);
|
||||
assert!(f.host.assets("other", "locale/").unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A plugin with web views and no code: its pages and their files are
|
||||
/// installed with it and served only from the declared directories, while
|
||||
/// it is enabled and only for the installed version; file icons and
|
||||
@@ -2798,6 +3131,30 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
|
||||
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
|
||||
|
||||
// File operations come from the `file-actions` export, next to the providers' own.
|
||||
let mut actions = f.host.provide_code_actions(&doc("files"), ide_core::Range::default()).into_iter();
|
||||
let (_, action) = actions.next().expect("offered");
|
||||
let (plugin, later) = actions.next().expect("a lazy one too");
|
||||
let dir = f.project.clone();
|
||||
assert_eq!(action.title, "Reorganise");
|
||||
assert_eq!(
|
||||
action.operations,
|
||||
vec![
|
||||
ide_plugin::FileOperation::Create { path: dir.join("created.txt"), text: "hello".into(), overwrite: false },
|
||||
ide_plugin::FileOperation::Rename { from: dir.join("old.txt"), to: dir.join("renamed.txt") },
|
||||
ide_plugin::FileOperation::Delete { path: dir.join("gone.txt") },
|
||||
]
|
||||
);
|
||||
assert_eq!(action.open, Some(dir.join("created.txt")));
|
||||
// Listed with `data` only: `resolve` on the same export computes its operations.
|
||||
assert!(later.file_action && later.operations.is_empty() && later.data.is_some());
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later).unwrap();
|
||||
assert_eq!(
|
||||
resolved.operations,
|
||||
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
|
||||
);
|
||||
assert!(f.host.provide_code_actions(&doc("rich"), ide_core::Range::default()).is_empty());
|
||||
|
||||
// A kind this IDE doesn't know is refused, not a trap.
|
||||
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no event kind"), "{}", err.message);
|
||||
@@ -3175,3 +3532,334 @@ fn invalid_plugin_icons_refuse_the_package() {
|
||||
assert!(err.contains(expected), "{id}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_exports_refresh_matching_files_and_follow_plugin_lifecycle() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
|
||||
let routes = f.project.join("routes.txt");
|
||||
std::fs::write(&routes, "/users/{id}\n").unwrap();
|
||||
f.install(&sample("endpoints"));
|
||||
let id = "test.endpoints";
|
||||
let paths = || f.host.endpoints(id, &f.project).unwrap().iter().map(|e| e.path.clone()).collect::<Vec<_>>();
|
||||
let first = f.host.endpoints(id, &f.project).unwrap();
|
||||
assert_eq!(first[0].path, "/users/{id}");
|
||||
assert!(first[0].method.is_none(), "any method is preserved");
|
||||
assert_eq!(first[0].location.path, ide_core::IpcPath::from(to_ipc(&routes)));
|
||||
std::thread::sleep(Duration::from_millis(70));
|
||||
f.host.tick();
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
assert_eq!(paths(), ["/users/{id}"]);
|
||||
assert_eq!(f.host.instances(), 0, "a cached list doesn't wake the plugin");
|
||||
let changed = |f: &Fixture| {
|
||||
f.take_events()
|
||||
.iter()
|
||||
.any(|(_, e)| matches!(e, PluginEvent::EndpointsChanged { plugin_id, .. } if plugin_id == id))
|
||||
};
|
||||
std::fs::write(&routes, "/updated\n").unwrap();
|
||||
f.take_events();
|
||||
f.host.invalidate_endpoints(&f.project, &[f.project.join("unrelated.txt")]);
|
||||
assert!(!changed(&f));
|
||||
assert_eq!(paths(), ["/users/{id}"]);
|
||||
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
|
||||
assert!(changed(&f), "windows are told to ask again");
|
||||
assert_eq!(paths(), ["/updated"]);
|
||||
std::fs::write(&routes, "not-a-path\n").unwrap();
|
||||
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
|
||||
assert_eq!(paths(), ["/updated"], "an invalid list doesn't replace the last valid one");
|
||||
f.host.set_enabled(id, false).unwrap();
|
||||
assert!(paths().is_empty());
|
||||
std::fs::write(&routes, "/enabled\n").unwrap();
|
||||
f.host.set_enabled(id, true).unwrap();
|
||||
assert_eq!(paths(), ["/enabled"]);
|
||||
f.take_events();
|
||||
assert!(f.run(id, "crash", None).is_err());
|
||||
assert!(changed(&f), "a crash withdraws the routes");
|
||||
f.host.uninstall(id).unwrap();
|
||||
assert!(f.host.endpoints(id, &f.project).is_err());
|
||||
}
|
||||
|
||||
struct EndpointWorkspace {
|
||||
reads: std::sync::atomic::AtomicUsize,
|
||||
inner: Arc<TestWorkspace>,
|
||||
roots: Mutex<Vec<PathBuf>>,
|
||||
entered: Mutex<Option<std::sync::mpsc::Sender<()>>>,
|
||||
release: Mutex<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
impl PluginWorkspace for EndpointWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
self.roots.lock().clone()
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
if self.reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0
|
||||
&& let Some(entered) = self.entered.lock().take()
|
||||
{
|
||||
entered.send(()).unwrap();
|
||||
self.release.lock().recv().unwrap();
|
||||
}
|
||||
self.inner.buffer_text(path)
|
||||
}
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
self.inner.document(path)
|
||||
}
|
||||
fn trusted(&self, root: &Path) -> bool {
|
||||
self.roots.lock().iter().any(|r| r == root)
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
self.roots.lock().first().cloned()
|
||||
}
|
||||
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
|
||||
self.inner.apply_edits(path, version, edits)
|
||||
}
|
||||
fn publish_diagnostics(
|
||||
&self,
|
||||
source: &str,
|
||||
path: &Path,
|
||||
items: Vec<ide_core::Diagnostic>,
|
||||
) -> Result<(), HostError> {
|
||||
self.inner.publish_diagnostics(source, path, items)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_endpoint_results_cannot_restore_a_disabled_plugin_or_closed_project() {
|
||||
for close_project in [false, true] {
|
||||
let f = fixture(Limits::default());
|
||||
std::fs::write(f.project.join("routes.txt"), "/late\n").unwrap();
|
||||
f.install(&sample("endpoints"));
|
||||
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let workspace = Arc::new(EndpointWorkspace {
|
||||
reads: std::sync::atomic::AtomicUsize::new(0),
|
||||
inner: f.workspace.clone(),
|
||||
roots: Mutex::new(vec![f.project.clone()]),
|
||||
entered: Mutex::new(Some(entered_tx)),
|
||||
release: Mutex::new(release_rx),
|
||||
});
|
||||
f.host.set_workspace(Some(workspace.clone()));
|
||||
let late = std::thread::scope(|scope| {
|
||||
// The plugin reads the file, then the host's check reads it again and waits here.
|
||||
let query = scope.spawn(|| f.host.endpoints("test.endpoints", &f.project).unwrap());
|
||||
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
if close_project {
|
||||
workspace.roots.lock().clear();
|
||||
} else {
|
||||
f.host.set_enabled("test.endpoints", false).unwrap();
|
||||
}
|
||||
release_tx.send(()).unwrap();
|
||||
query.join().unwrap()
|
||||
});
|
||||
assert!(late.is_empty());
|
||||
assert!(f.host.endpoints("test.endpoints", &f.project).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_results_are_isolated_per_root_and_withdraw_closed_roots() {
|
||||
let f = fixture(Limits::default());
|
||||
let other = f.project.parent().unwrap().join("other");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
std::fs::write(f.project.join("routes.txt"), "/first\n").unwrap();
|
||||
std::fs::write(other.join("routes.txt"), "/second\n").unwrap();
|
||||
let (_, release_rx) = std::sync::mpsc::channel();
|
||||
let workspace = Arc::new(EndpointWorkspace {
|
||||
reads: std::sync::atomic::AtomicUsize::new(0),
|
||||
inner: f.workspace.clone(),
|
||||
roots: Mutex::new(vec![f.project.clone(), other.clone()]),
|
||||
entered: Mutex::new(None),
|
||||
release: Mutex::new(release_rx),
|
||||
});
|
||||
f.host.set_workspace(Some(workspace.clone()));
|
||||
f.install(&sample("endpoints"));
|
||||
for (root, route) in [(&f.project, "/first"), (&other, "/second")] {
|
||||
let list = f.host.endpoints("test.endpoints", root).unwrap();
|
||||
assert_eq!(list.iter().map(|e| e.path.as_str()).collect::<Vec<_>>(), [route]);
|
||||
}
|
||||
workspace.roots.lock().retain(|r| r != &other);
|
||||
assert!(f.host.endpoints("test.endpoints", &other).unwrap().is_empty(), "a closed project has no routes");
|
||||
assert_eq!(f.host.endpoints("test.endpoints", &f.project).unwrap()[0].path, "/first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_actions_keep_commands_and_window_scope() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.take_events();
|
||||
f.host
|
||||
.run_command("jobs", "notify-actions", None, Invocation { client: Some("second".into()), ..f.inv() })
|
||||
.unwrap();
|
||||
let notifications = f
|
||||
.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, event)| match event {
|
||||
PluginEvent::Notification { plugin_id, actions, .. } => Some((client, plugin_id, actions)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
assert_eq!(notifications[0].0.as_deref(), Some("second"));
|
||||
assert_eq!(notifications[0].1, "jobs");
|
||||
assert_eq!(notifications[0].2.len(), 3);
|
||||
assert_eq!(notifications[0].2[0].command, "context");
|
||||
assert_eq!(notifications[0].2[0].argument.as_deref(), Some("notification-action=1\nvalue"));
|
||||
for invalid in ["four", "unknown"] {
|
||||
let error = f.run("jobs", "notify-actions", Some(invalid)).unwrap_err();
|
||||
assert!(error.contains(if invalid == "four" { "at most 3" } else { "declares no command" }), "{error}");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::Notification { .. })));
|
||||
}
|
||||
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
|
||||
use ide_plugin::Prompt;
|
||||
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
|
||||
f.install(&sample("jobs"));
|
||||
f.host.set_capability("jobs", "project.read", false).unwrap();
|
||||
let (result, prompt) =
|
||||
run_and_answer(&f, "pick-directory", Some("C:/work"), Some(&["C:/work/repo"]), Duration::from_millis(700));
|
||||
assert_eq!(result.unwrap().as_deref(), Some("C:/work/repo"));
|
||||
assert_eq!(prompt, Prompt::PickDirectory { title: "Clone target".into(), initial: Some("C:/work".into()) });
|
||||
assert_eq!(run_and_answer(&f, "pick-directory", None, None, Duration::ZERO).0.unwrap().as_deref(), Some("none"));
|
||||
assert!(run_and_answer(&f, "pick-directory", None, Some(&["relative"]), Duration::ZERO).0.is_err());
|
||||
assert!(f.run("jobs", "pick-directory", Some("relative")).is_err());
|
||||
for (cmd, new_window) in [("open-project", false), ("open-project-new", true)] {
|
||||
f.take_events();
|
||||
let inv = Invocation { client: Some("second".into()), ..f.inv() };
|
||||
f.host.run_command("jobs", cmd, Some("C:/work/repo"), inv).unwrap();
|
||||
let requests = f
|
||||
.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, event)| match event {
|
||||
PluginEvent::OpenProject { plugin_id, path, new_window } => Some((client, plugin_id, path, new_window)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(requests, vec![(Some("second".into()), "jobs".into(), "C:/work/repo".into(), new_window)]);
|
||||
let error = f.host.run_command("jobs", cmd, Some("/work/repo"), Invocation::default()).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput, "{error:?}");
|
||||
}
|
||||
assert!(f.host.run_command("jobs", "pick-directory", None, Invocation::default()).is_err());
|
||||
for path in ["relative", "/bad\npath", ""] {
|
||||
f.take_events();
|
||||
assert!(f.run("jobs", "open-project", Some(path)).is_err());
|
||||
assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_readonly_fields_and_table_row_extensions_round_trip() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("tables"));
|
||||
let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap();
|
||||
let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") };
|
||||
assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into()));
|
||||
assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false });
|
||||
assert_eq!(
|
||||
fields[2].value,
|
||||
ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true }
|
||||
);
|
||||
assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_)));
|
||||
for (event, status) in [
|
||||
(ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"),
|
||||
(ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"),
|
||||
] {
|
||||
f.take_events();
|
||||
f.host.view_event("tables", "tables", event, f.inv()).unwrap();
|
||||
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables")));
|
||||
assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
|
||||
f.install(&sample("path-events"));
|
||||
f.run("test.path-events", "count", None).unwrap();
|
||||
let matched = to_ipc(&f.project.join("config/app.yaml"));
|
||||
let other = to_ipc(&f.project.join("src/main.rs"));
|
||||
let deliver = |events| {
|
||||
f.host.post_events(Some("main"), events);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
};
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
f.host.tick();
|
||||
assert_eq!(f.status("test.path-events"), PluginStatus::Inactive);
|
||||
let activations = f.host.info_of("test.path-events").unwrap().stats.activations;
|
||||
deliver(vec![WorkbenchEvent::FileOpened(other.clone()), WorkbenchEvent::FileSaved(other.clone())]);
|
||||
assert_eq!(f.host.info_of("test.path-events").unwrap().stats.activations, activations);
|
||||
deliver(vec![WorkbenchEvent::FileOpened(matched.clone()), WorkbenchEvent::FileSaved(matched.clone())]);
|
||||
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("2"));
|
||||
assert!(f.run("test.path-events", "filter", Some("../*")).is_err());
|
||||
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "count", None).unwrap().as_deref(),
|
||||
Some("3"),
|
||||
"invalid filter preserves previous one"
|
||||
);
|
||||
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(matched.clone())]);
|
||||
f.run("test.path-events", "filter", None).unwrap();
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "count", None).unwrap().as_deref(),
|
||||
Some("3"),
|
||||
"queued event uses current filter"
|
||||
);
|
||||
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
|
||||
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("3"));
|
||||
f.run("test.path-events", "all", None).unwrap();
|
||||
deliver(vec![WorkbenchEvent::FileOpened(other.clone())]);
|
||||
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(other.clone())]);
|
||||
f.run("test.path-events", "off", None).unwrap();
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
deliver(vec![WorkbenchEvent::FileOpened(other)]);
|
||||
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
|
||||
|
||||
f.host.set_enabled("test.path-events", false).unwrap();
|
||||
assert!(!f.host.event_kinds().contains(&WorkbenchEvent::FileOpened(String::new()).kind()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("path-events"));
|
||||
let path = f.project.join("src/notes.txt");
|
||||
assert_eq!(f.run("test.path-events", "project", Some(&to_ipc(&path))).unwrap(), Some(to_ipc(&f.project)));
|
||||
assert_eq!(f.run("test.path-events", "project", Some("src/notes.txt")).unwrap(), None);
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "project", Some(&to_ipc(&f.project.with_extension("other").join("a")))).unwrap(),
|
||||
None
|
||||
);
|
||||
let doc = ide_plugin::ProviderDocument { path, language: "text".into(), version: 1, text: "one".into() };
|
||||
let hovers = f.host.provide_hover(&doc, ide_core::Pos::new(0, 0));
|
||||
assert_eq!(hovers.len(), 1);
|
||||
assert_eq!(hovers[0].markdown, to_ipc(&f.project));
|
||||
let nested = f.project.join("nested");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
f.workspace.other_roots.lock().push(nested.clone());
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "project", Some(&to_ipc(&nested.join("new.txt")))).unwrap(),
|
||||
Some(to_ipc(&nested))
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&nested, f.project.join("alias")).unwrap();
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("alias/new.txt")))).unwrap(),
|
||||
Some(to_ipc(&nested))
|
||||
);
|
||||
std::os::unix::fs::symlink(f._tmp.path(), f.project.join("outside")).unwrap();
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("outside/new.txt")))).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
f.host.set_capability("test.path-events", "project.read", false).unwrap();
|
||||
assert!(f.run("test.path-events", "project", Some(&to_ipc(&doc.path))).unwrap_err().contains("project.read"));
|
||||
f.host.set_capability("test.path-events", "events", false).unwrap();
|
||||
assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events"));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+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
-16
@@ -1385,14 +1385,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 +1444,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 +1475,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 +1612,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)
|
||||
};
|
||||
|
||||
@@ -1891,11 +1891,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 +1925,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
|
||||
@@ -3322,17 +3314,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 +4128,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();
|
||||
|
||||
@@ -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;
|
||||
@@ -4,20 +4,20 @@
|
||||
//! themselves are in `wit_host`.
|
||||
|
||||
use crate::types::{
|
||||
CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
|
||||
ProviderDocument, SemanticToken,
|
||||
CodeAction, CommitDraft, CommitProblem, CompletionItem, FileOperation, InlineSuggestion, LineMarker, Location,
|
||||
PluginHover, PluginInlayHint, ProviderDocument, SemanticToken,
|
||||
};
|
||||
use crate::types::{
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
|
||||
};
|
||||
use crate::types::{
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent,
|
||||
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
|
||||
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{
|
||||
bindings, commit_checks_bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings,
|
||||
services_bindings, syntax_bindings, views_bindings,
|
||||
bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings,
|
||||
indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
|
||||
};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
@@ -35,12 +35,15 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
use bindings::openidea::plugin::events as wit_events;
|
||||
use bindings::openidea::plugin::types as wit_types;
|
||||
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
|
||||
use details_bindings::exports::openidea::plugin::detail_provider as wit_detail;
|
||||
use details_bindings::openidea::plugin::detail_types as wit_dt;
|
||||
use endpoints_bindings::exports::openidea::plugin::endpoints_provider as wit_endpoints;
|
||||
use file_actions_bindings::exports::openidea::plugin::file_actions as wit_fa;
|
||||
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
|
||||
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
|
||||
use inlay_bindings::exports::openidea::plugin::inlay_hints_provider as wit_inlay;
|
||||
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
|
||||
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
|
||||
use services_bindings::exports::openidea::plugin::service_provider as wit_svc;
|
||||
use services_bindings::openidea::plugin::service_types as wit_st;
|
||||
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
|
||||
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
@@ -58,7 +61,15 @@ pub const CANCELLED: &str = "was cancelled";
|
||||
/// which routes notifications and status items to windows and edits to open
|
||||
/// documents. Capabilities are already checked when these are called.
|
||||
pub trait HostApi: Send + Sync {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
|
||||
/// Shows a notification; `actions` name commands the plugin declares.
|
||||
fn notify(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<crate::NotificationAction>,
|
||||
) -> Result<(), HostError>;
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let _ = (client, text);
|
||||
Ok(())
|
||||
@@ -67,6 +78,10 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (client, url);
|
||||
Ok(())
|
||||
}
|
||||
fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> {
|
||||
let _ = (client, path, new_window);
|
||||
Err(HostError::Invalid("opening projects is unavailable".into()))
|
||||
}
|
||||
/// Shows `prompt` in `client`'s window and waits for the answer until the
|
||||
/// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the
|
||||
/// text (`Input`, one element) or any value (`Confirm`: OK); `None` when
|
||||
@@ -95,6 +110,17 @@ pub trait HostApi: Send + Sync {
|
||||
}
|
||||
/// Roots of the open projects.
|
||||
fn projects(&self) -> Vec<std::path::PathBuf>;
|
||||
/// The most specific open project containing a document (including a new file).
|
||||
fn project_of(&self, path: &Path) -> Option<std::path::PathBuf> {
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let real = crate::resolve_real(path);
|
||||
self.projects()
|
||||
.into_iter()
|
||||
.filter(|root| real.starts_with(crate::resolve_real(root)))
|
||||
.max_by_key(|root| root.components().count())
|
||||
}
|
||||
/// Unsaved text of an open editor's file.
|
||||
fn buffer_text(&self, path: &Path) -> Option<String>;
|
||||
/// Version and text of the open document at `path`.
|
||||
@@ -151,7 +177,11 @@ pub trait HostApi: Send + Sync {
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<ProcessOutput, HostError>;
|
||||
fn subscribe(&self, kind: EventKind, on: bool);
|
||||
/// Delivers every event of `kind`, dropping its path filter.
|
||||
fn subscribe(&self, kind: EventKind);
|
||||
/// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`.
|
||||
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError>;
|
||||
fn unsubscribe(&self, kind: &str);
|
||||
/// Starts a progress indicator; cancelling it from the UI cancels `cancel`.
|
||||
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64;
|
||||
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>);
|
||||
@@ -172,8 +202,21 @@ pub trait HostApi: Send + Sync {
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), HostError>;
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
|
||||
/// The IDE's UI language as a BCP 47 tag.
|
||||
fn locale(&self) -> String;
|
||||
/// A setting of the plugin as JSON (project over global over the schema default).
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
|
||||
/// Reads and decodes a text file outside the project, whose real path an
|
||||
/// `fs.read` grant already covers: the unsaved buffer wins, then the disk.
|
||||
/// `max` bounds the bytes read.
|
||||
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError>;
|
||||
/// Replaces the text of a file whose real path the caller already checked:
|
||||
/// in one step, keeping its encoding and BOM (a new file is UTF-8). Refused
|
||||
/// while an editor holds unsaved changes for it.
|
||||
fn write_text_file(&self, real: &Path, text: &str) -> Result<(), HostError>;
|
||||
/// Replaces the bytes of a file whose real path the caller already checked,
|
||||
/// in one step. Refused while an editor holds unsaved changes for it.
|
||||
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError>;
|
||||
}
|
||||
|
||||
/// Shared compilation engine plus an epoch ticker thread for wall-clock limits.
|
||||
@@ -540,11 +583,14 @@ struct Exports {
|
||||
syntax: Option<wit_syntax::Guest>,
|
||||
providers: Option<wit_prov::Guest>,
|
||||
indexer: Option<wit_index::Guest>,
|
||||
file_actions: Option<wit_fa::Guest>,
|
||||
views: Option<wit_view::Guest>,
|
||||
semantic: Option<wit_sem::Guest>,
|
||||
services: Option<wit_svc::Guest>,
|
||||
details: Option<wit_detail::Guest>,
|
||||
hover: Option<wit_hover::Guest>,
|
||||
commit_checks: Option<wit_cc::Guest>,
|
||||
inlay: Option<wit_inlay::Guest>,
|
||||
endpoints: Option<wit_endpoints::Guest>,
|
||||
}
|
||||
|
||||
/// Most results one syntax call may return (more are dropped).
|
||||
@@ -622,12 +668,15 @@ impl Runtime {
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
|
||||
let index_indices = wit_index::GuestIndices::new(&pre).ok();
|
||||
let file_action_indices = wit_fa::GuestIndices::new(&pre).ok();
|
||||
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
|
||||
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
|
||||
let detail_indices = wit_detail::GuestIndices::new(&pre).ok();
|
||||
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
|
||||
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
|
||||
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
|
||||
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
|
||||
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
|
||||
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
|
||||
let syntax = match syntax_indices {
|
||||
@@ -642,6 +691,10 @@ impl Runtime {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let file_actions = match file_action_indices {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let views = match view_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
@@ -650,7 +703,7 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let services = match service_indices {
|
||||
let details = match detail_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
@@ -662,9 +715,30 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let inlay = match inlay_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let endpoints = match endpoint_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover, commit_checks },
|
||||
exports: Exports {
|
||||
plugin,
|
||||
syntax,
|
||||
providers,
|
||||
views,
|
||||
indexer,
|
||||
semantic,
|
||||
details,
|
||||
hover,
|
||||
commit_checks,
|
||||
inlay,
|
||||
endpoints,
|
||||
file_actions,
|
||||
},
|
||||
limits: setup.limits,
|
||||
version: setup.version,
|
||||
})
|
||||
@@ -846,9 +920,23 @@ impl Runtime {
|
||||
selection: ide_core::Range,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<CodeAction>, CallError> {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
|
||||
let mut out = Vec::new();
|
||||
if self.exports.providers.is_some() {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit));
|
||||
}
|
||||
if self.exports.file_actions.is_some() {
|
||||
let d = file_action_doc(doc);
|
||||
let actions = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.file_actions.as_ref().expect("checked").call_code_actions(store, &d, range_to_wit(selection))
|
||||
})?;
|
||||
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(file_action_from_wit));
|
||||
}
|
||||
if out.is_empty() && self.exports.providers.is_none() {
|
||||
return Err(CallError::missing_export("providers"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The edits of an action `provide_code_actions` returned with `data`.
|
||||
@@ -858,6 +946,17 @@ impl Runtime {
|
||||
action: &CodeAction,
|
||||
timeout: Duration,
|
||||
) -> Result<CodeAction, CallError> {
|
||||
if action.file_action {
|
||||
if self.exports.file_actions.is_none() {
|
||||
return Err(CallError::missing_export("file-actions"));
|
||||
}
|
||||
let d = file_action_doc(doc);
|
||||
let a = file_action_to_wit(action);
|
||||
let resolved = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.file_actions.as_ref().expect("checked").call_resolve(store, &d, &a)
|
||||
})?;
|
||||
return resolved.map(file_action_from_wit).map_err(|e| CallError::Guest(e.into()));
|
||||
}
|
||||
let d = doc_to_wit(doc);
|
||||
let a = code_action_to_wit(action);
|
||||
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
|
||||
@@ -945,6 +1044,40 @@ impl Runtime {
|
||||
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
|
||||
}
|
||||
|
||||
pub fn provide_inlay_hints(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
range: ide_core::Range,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<PluginInlayHint>, CallError> {
|
||||
if self.exports.inlay.is_none() {
|
||||
return Err(CallError::missing_export("inlay-hints-provider"));
|
||||
}
|
||||
let d = wit_inlay::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let r = wit_types::Range { start: pos_to_wit(range.start), end: pos_to_wit(range.end) };
|
||||
let hints = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.inlay.as_ref().expect("checked").call_inlay_hints(store, &d, r)
|
||||
})?;
|
||||
Ok(hints
|
||||
.into_iter()
|
||||
.filter(|h| !h.label.trim().is_empty())
|
||||
.take(MAX_PROVIDER_ITEMS)
|
||||
.map(|h| PluginInlayHint {
|
||||
position: ide_core::Pos::new(h.position.line, h.position.character),
|
||||
label: clip(h.label, 256),
|
||||
kind: clip(h.kind, 32),
|
||||
tooltip: h.tooltip.map(|t| clip(t, 1024)),
|
||||
padding_left: h.padding_left,
|
||||
padding_right: h.padding_right,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn activate(&mut self) -> Result<(), CallError> {
|
||||
let data = self.store.data();
|
||||
let ctx = wit_types::Context {
|
||||
@@ -1010,6 +1143,37 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Project routes, with an explicit project invocation and background budget.
|
||||
pub fn endpoints(&mut self, invocation: Invocation, timeout: Duration) -> Result<Vec<crate::Endpoint>, CallError> {
|
||||
if self.exports.endpoints.is_none() {
|
||||
return Err(CallError::missing_export("endpoints-provider"));
|
||||
}
|
||||
let result = self.call(invocation, timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let r = e
|
||||
.endpoints
|
||||
.as_ref()
|
||||
.expect("checked")
|
||||
.call_endpoints(&mut *store, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
let entries = result.map_err(|e| CallError::Guest(e.into()))?;
|
||||
if entries.len() > MAX_PROVIDER_ITEMS {
|
||||
return Err(CallError::Guest(GuestError::Invalid("too many endpoints".into())));
|
||||
}
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|e| crate::Endpoint {
|
||||
method: e.method,
|
||||
path: e.path,
|
||||
framework: e.framework,
|
||||
handler: e.handler,
|
||||
location: location_from_wit(e.location),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The Services pane of node `key` of the plugin's services view `view`.
|
||||
pub fn service_detail(
|
||||
&mut self,
|
||||
@@ -1017,13 +1181,13 @@ impl Runtime {
|
||||
key: &str,
|
||||
invocation: Invocation,
|
||||
) -> Result<Vec<ServiceTab>, CallError> {
|
||||
if self.exports.services.is_none() {
|
||||
return Err(CallError::missing_export("service-provider"));
|
||||
if self.exports.details.is_none() {
|
||||
return Err(CallError::missing_export("detail-provider"));
|
||||
}
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let services = e.services.as_ref().expect("checked");
|
||||
let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
let details = e.details.as_ref().expect("checked");
|
||||
let r = details.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
@@ -1227,7 +1391,10 @@ fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
file_action: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1251,6 +1418,82 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_FILE_OPERATIONS: usize = 1000;
|
||||
|
||||
fn file_action_doc(doc: &ProviderDocument) -> wit_fa::Document {
|
||||
wit_fa::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_from_wit(a: wit_fa::FileAction) -> CodeAction {
|
||||
CodeAction {
|
||||
title: clip(a.title, MAX_TITLE),
|
||||
kind: a.kind.map(|k| clip(k, 64)),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
operations: a.operations.into_iter().take(MAX_FILE_OPERATIONS).map(operation_from_wit).collect(),
|
||||
open: a.open.map(std::path::PathBuf::from),
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
file_action: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_to_wit(a: &CodeAction) -> wit_fa::FileAction {
|
||||
wit_fa::FileAction {
|
||||
title: a.title.clone(),
|
||||
kind: a.kind.clone(),
|
||||
preferred: a.preferred,
|
||||
operations: a
|
||||
.operations
|
||||
.iter()
|
||||
.map(|op| match op {
|
||||
FileOperation::Create { path, text, overwrite } => wit_fa::FileOperation::Create(wit_fa::CreateFile {
|
||||
path: to_ipc(path),
|
||||
text: text.clone(),
|
||||
overwrite: *overwrite,
|
||||
}),
|
||||
FileOperation::Rename { from, to } => {
|
||||
wit_fa::FileOperation::Rename(wit_fa::RenameFile { path: to_ipc(from), new_path: to_ipc(to) })
|
||||
}
|
||||
FileOperation::Delete { path } => {
|
||||
wit_fa::FileOperation::Delete(wit_fa::DeleteFile { path: to_ipc(path) })
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
edits: a
|
||||
.edits
|
||||
.iter()
|
||||
.map(|(path, edits)| wit_fa::FileEdit {
|
||||
path: to_ipc(path),
|
||||
edits: edits
|
||||
.iter()
|
||||
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
open: a.open.as_deref().map(to_ipc),
|
||||
data: a.data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_from_wit(op: wit_fa::FileOperation) -> FileOperation {
|
||||
match op {
|
||||
wit_fa::FileOperation::Create(c) => {
|
||||
FileOperation::Create { path: c.path.into(), text: c.text, overwrite: c.overwrite }
|
||||
}
|
||||
wit_fa::FileOperation::Rename(r) => FileOperation::Rename { from: r.path.into(), to: r.new_path.into() },
|
||||
wit_fa::FileOperation::Delete(d) => FileOperation::Delete { path: d.path.into() },
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
@@ -1270,11 +1513,11 @@ const MAX_LOG_TOGGLES: usize = 8;
|
||||
const MAX_LOG_ARGS: usize = 256;
|
||||
const MAX_LOG_ARG: usize = 4096;
|
||||
|
||||
fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
|
||||
fn service_tab_from_wit(t: wit_dt::Tab) -> ServiceTab {
|
||||
let body = match t.body {
|
||||
wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
|
||||
wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
|
||||
wit_st::Pane::Log(log) => {
|
||||
wit_dt::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
|
||||
wit_dt::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
|
||||
wit_dt::Pane::Log(log) => {
|
||||
let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG);
|
||||
if args_fit(&log.args)
|
||||
&& log.toggles.len() <= MAX_LOG_TOGGLES
|
||||
@@ -1299,12 +1542,54 @@ fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
|
||||
ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body }
|
||||
}
|
||||
|
||||
/// A `table` body carried by the `other` case (the sortable variant, plugins/
|
||||
/// README.md §10): the usual table fields as JSON plus `sort`, the column the
|
||||
/// plugin orders by and the direction, or null. Anything unparsable degrades
|
||||
/// like a body this host doesn't know.
|
||||
fn sortable_table_from_json(data: &str) -> Option<ViewTable> {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Json {
|
||||
#[serde(default)]
|
||||
columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
total_rows: u32,
|
||||
#[serde(default)]
|
||||
first_row: u32,
|
||||
#[serde(default)]
|
||||
rows: Vec<Vec<String>>,
|
||||
#[serde(default)]
|
||||
editable: bool,
|
||||
#[serde(default)]
|
||||
sort: Option<TableSort>,
|
||||
}
|
||||
let json: Json = serde_json::from_str(data).ok()?;
|
||||
Some(ViewTable {
|
||||
columns: json.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(),
|
||||
total_rows: json.total_rows,
|
||||
first_row: json.first_row,
|
||||
rows: json
|
||||
.rows
|
||||
.into_iter()
|
||||
.take(MAX_TABLE_ROWS as usize)
|
||||
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
|
||||
.collect(),
|
||||
editable: json.editable,
|
||||
sortable: true,
|
||||
sort: json.sort,
|
||||
})
|
||||
}
|
||||
|
||||
fn view_from_wit(v: wit_vt::View) -> View {
|
||||
let body = match v.body {
|
||||
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
|
||||
wit_vt::Body::Other(e) => {
|
||||
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
|
||||
}
|
||||
wit_vt::Body::Other(e) => match e.kind.as_str() {
|
||||
"table" => match sortable_table_from_json(&e.data) {
|
||||
Some(t) => ViewBody::Table(t),
|
||||
None => ViewBody::Message("This view needs a newer version of OpenIDEA (table).".into()),
|
||||
},
|
||||
_ => ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64))),
|
||||
},
|
||||
wit_vt::Body::Tree(items) => ViewBody::Tree(
|
||||
items
|
||||
.into_iter()
|
||||
@@ -1345,8 +1630,23 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
|
||||
selected: clip(selected, MAX_TITLE),
|
||||
},
|
||||
// A field type of a newer API: left out.
|
||||
wit_vt::FieldValue::Other(_) => return None,
|
||||
wit_vt::FieldValue::Other(e) => match e.kind.as_str() {
|
||||
"label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)),
|
||||
"readonly-text" => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ReadonlyText {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
multiline: bool,
|
||||
}
|
||||
let value: ReadonlyText = serde_json::from_str(&e.data).ok()?;
|
||||
FieldValue::ReadonlyText {
|
||||
text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }),
|
||||
multiline: value.multiline,
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
};
|
||||
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
|
||||
})
|
||||
@@ -1363,6 +1663,9 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
|
||||
.collect(),
|
||||
editable: t.editable,
|
||||
// The plain body predates sortable headers; `other` kind `table` opts in.
|
||||
sortable: false,
|
||||
sort: None,
|
||||
}),
|
||||
wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)),
|
||||
};
|
||||
@@ -1396,6 +1699,18 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
ViewEvent::InsertRow { before } => W::InsertRow(before),
|
||||
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
|
||||
ViewEvent::Message { text } => W::Message(text),
|
||||
ViewEvent::TableSelect { row } => {
|
||||
W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) })
|
||||
}
|
||||
ViewEvent::TableActivate { row } => {
|
||||
W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) })
|
||||
}
|
||||
// A sortable table's header click, as an `other` view-event (plugins/
|
||||
// README.md §10): the SDKs decode the kinds they know.
|
||||
ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension {
|
||||
kind: "sort".into(),
|
||||
data: format!(r#"{{"column":{column},"descending":{descending}}}"#),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1403,6 +1718,44 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() {
|
||||
let fields = [
|
||||
("label", r#""Title \"quoted\"""#),
|
||||
("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#),
|
||||
("readonly-text", r#"{"text":"author"}"#),
|
||||
("label", "{}"),
|
||||
("readonly-text", r#"{"text":false}"#),
|
||||
("future", "null"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (kind, data))| wit_vt::Field {
|
||||
key: i.to_string(),
|
||||
label: "Field".into(),
|
||||
value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }),
|
||||
})
|
||||
.collect();
|
||||
let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None });
|
||||
let ViewBody::Form(fields) = view.body else { panic!("form") };
|
||||
assert_eq!(fields.len(), 3);
|
||||
assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into()));
|
||||
assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true });
|
||||
assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_row_events_leave_through_released_extension_case() {
|
||||
for (event, kind) in [
|
||||
(ViewEvent::TableSelect { row: 51 }, "table-select"),
|
||||
(ViewEvent::TableActivate { row: 51 }, "table-activate"),
|
||||
] {
|
||||
let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") };
|
||||
assert_eq!(extension.kind, kind);
|
||||
assert_eq!(extension.data, r#"{"row":51}"#);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn provider_and_resolved_action_paths_match_ipc_events() {
|
||||
@@ -1418,7 +1771,10 @@ mod tests {
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits: vec![(path.into(), Vec::new())],
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: None,
|
||||
file_action: false,
|
||||
};
|
||||
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
|
||||
}
|
||||
@@ -1441,4 +1797,41 @@ mod tests {
|
||||
let ViewBody::Form(fields) = form.body else { panic!("a form") };
|
||||
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sortable_table_arrives_through_other() {
|
||||
let view = |data: &str| wit_vt::View {
|
||||
toolbar: Vec::new(),
|
||||
body: wit_vt::Body::Other(wit_types::Extension { kind: "table".into(), data: data.into() }),
|
||||
status: None,
|
||||
};
|
||||
let shown = view_from_wit(view(
|
||||
r#"{"columns":["ext","files"],"totalRows":3,"firstRow":1,"rows":[["rs","2"],["md","1"]],
|
||||
"editable":false,"sort":{"column":1,"descending":true}}"#,
|
||||
));
|
||||
let ViewBody::Table(t) = shown.body else { panic!("{shown:?}") };
|
||||
assert_eq!(t.columns, ["ext".to_owned(), "files".into()]);
|
||||
assert_eq!((t.total_rows, t.first_row, t.editable), (3, 1, false));
|
||||
assert!(t.sortable, "a `table` through `other` opts the headers in");
|
||||
assert_eq!(t.sort, Some(TableSort { column: 1, descending: true }));
|
||||
|
||||
// No sort yet: still clickable headers, just no arrow.
|
||||
let fresh = view_from_wit(view(
|
||||
r#"{"columns":["a"],"totalRows":0,"firstRow":0,"rows":[],"editable":false,"sort":null}"#,
|
||||
));
|
||||
let ViewBody::Table(t) = fresh.body else { panic!("{fresh:?}") };
|
||||
assert!(t.sortable);
|
||||
assert_eq!(t.sort, None);
|
||||
|
||||
// Broken JSON degrades like any body the host doesn't know.
|
||||
let broken = view_from_wit(view("{not json"));
|
||||
assert!(matches!(broken.body, ViewBody::Message(_)), "{broken:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sort_click_leaves_as_an_other_extension() {
|
||||
let wit = event_to_wit_view(ViewEvent::Sort { column: 2, descending: true });
|
||||
let wit_vt::ViewEvent::Other(e) = wit else { panic!("{wit:?}") };
|
||||
assert_eq!((e.kind.as_str(), e.data.as_str()), ("sort", r#"{"column":2,"descending":true}"#));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,63 @@
|
||||
//! Capability checks for host functions that touch the file system.
|
||||
|
||||
use ide_extension::Capability;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Largest file `read-file` returns.
|
||||
pub const MAX_READ_BYTES: u64 = 16 << 20;
|
||||
/// The most entries `fs.read-dir` hands over: the same cap as `find-files`.
|
||||
pub const MAX_LIST_ENTRIES: usize = 4096;
|
||||
|
||||
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
|
||||
/// path — after following `..` and symlinks — stays inside `root`.
|
||||
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
pub const MAX_READ_BYTES: u64 = 16 << 20;
|
||||
/// The largest file s.write may write, in bytes.
|
||||
pub const MAX_WRITE_BYTES: usize = 16 << 20;
|
||||
|
||||
/// The canonical `root` and the absolute form of `path` (absolute, or
|
||||
/// relative to `root`) — before any symlink or existence check.
|
||||
fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||
if path.is_empty() {
|
||||
return Err("empty path".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let requested = Path::new(path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
Ok((root, joined))
|
||||
}
|
||||
|
||||
/// `joined` after `..`/symlink resolution as far as the file system allows:
|
||||
/// the canonical path when it exists, else the canonicalized deepest existing
|
||||
/// ancestor with the still-missing tail resolved textually — so the location
|
||||
/// of a missing path is still judged where it would really land.
|
||||
pub fn resolve_real(joined: &Path) -> PathBuf {
|
||||
let mut tail = Vec::new();
|
||||
let mut candidate = joined.to_path_buf();
|
||||
let mut real = loop {
|
||||
match candidate.canonicalize() {
|
||||
Ok(real) => break real,
|
||||
Err(_) => {
|
||||
let Some(last) = candidate.components().next_back() else {
|
||||
return joined.to_path_buf();
|
||||
};
|
||||
tail.push(last.as_os_str().to_owned());
|
||||
if !candidate.pop() {
|
||||
return joined.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for part in tail.iter().rev() {
|
||||
if part == ".." {
|
||||
real.pop();
|
||||
} else if part != "." {
|
||||
real.push(part);
|
||||
}
|
||||
}
|
||||
real
|
||||
}
|
||||
|
||||
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
|
||||
/// path — after following `..` and symlinks — stays inside `root`.
|
||||
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
let real = joined.canonicalize().map_err(|_| format!("{path}: no such file"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{path}: access outside the project root is not allowed"));
|
||||
@@ -21,6 +65,58 @@ pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
|
||||
/// without requiring it to exist: `Ok(true)` also for a missing path, so
|
||||
/// callers distinguish "outside" from "absent". Existing symlinks are
|
||||
/// followed; a missing tail is judged at its would-be location.
|
||||
pub fn inside_root(root: &Path, path: &str) -> Result<bool, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
Ok(resolve_real(&joined).starts_with(&root))
|
||||
}
|
||||
|
||||
/// Like [`resolve_in_root`] for a path that may not exist yet (a file about to
|
||||
/// be created) or that names a symlink itself (to delete or rename it): the
|
||||
/// directory part is resolved through symlinks, the final name is kept as is,
|
||||
/// and the result must stay inside `root`.
|
||||
pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let outside = || format!("{path}: access outside the project root is not allowed");
|
||||
if path.is_empty() {
|
||||
return Err("empty path".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let requested = Path::new(path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
let mut lexical = PathBuf::new();
|
||||
for part in joined.components() {
|
||||
match part {
|
||||
std::path::Component::ParentDir => {
|
||||
lexical.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
part => lexical.push(part),
|
||||
}
|
||||
}
|
||||
let (Some(name), Some(parent)) = (lexical.file_name(), lexical.parent()) else {
|
||||
return Err(outside());
|
||||
};
|
||||
let mut missing = Vec::new();
|
||||
let mut existing = parent;
|
||||
while !existing.exists() {
|
||||
let (Some(dir), Some(above)) = (existing.file_name(), existing.parent()) else {
|
||||
return Err(outside());
|
||||
};
|
||||
missing.push(dir);
|
||||
existing = above;
|
||||
}
|
||||
let mut real = existing.canonicalize().map_err(|e| format!("{path}: {e}"))?;
|
||||
real.extend(missing.into_iter().rev());
|
||||
real.push(name);
|
||||
if !real.starts_with(&root) {
|
||||
return Err(outside());
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 file that `resolve_in_root` already approved.
|
||||
pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
@@ -34,6 +130,132 @@ pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display()))
|
||||
}
|
||||
|
||||
/// Why a read outside the project was refused.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// No `fs.read:<path>` grant covers the file; names the capability to declare.
|
||||
Denied(String),
|
||||
/// The path cannot be read as asked.
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
/// Expands a leading `~` or `~/` to the user's home; everything else as written.
|
||||
fn expand_tilde(path: &str) -> PathBuf {
|
||||
let rest = if path == "~" {
|
||||
Some("")
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
Some(rest)
|
||||
} else {
|
||||
path.strip_prefix("~\\")
|
||||
};
|
||||
match rest {
|
||||
Some(rest) => std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(|home| PathBuf::from(home).join(rest))
|
||||
.unwrap_or_else(|| PathBuf::from(path)),
|
||||
None => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which grant a path is resolved against.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Wanted {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
impl Wanted {
|
||||
fn capability(self, plain: String) -> Capability {
|
||||
match self {
|
||||
Self::Read => Capability::FsRead(plain),
|
||||
Self::Write => Capability::FsWrite(plain),
|
||||
}
|
||||
}
|
||||
|
||||
/// `fs.read` / `fs.write`, for messages.
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "fs.read",
|
||||
Self::Write => "fs.write",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The real path of `path` when an `fs.read:<path>` grant covers it.
|
||||
///
|
||||
/// The grant is compared with the **real** path, so a symlink pointing out of
|
||||
/// a granted tree is refused, and `fs.read:/srv/app` covers `/srv/app/x` but
|
||||
/// not `/srv/application`. Windows' `\\?\` prefix (what `canonicalize` adds)
|
||||
/// is stripped from the file side so a manifest may spell `C:\Program Files\X`.
|
||||
pub fn resolve_granted(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
|
||||
resolve(granted, path, Wanted::Read)
|
||||
}
|
||||
|
||||
/// The real path `path` would be written to, when an `fs.write:<path>` grant
|
||||
/// covers it.
|
||||
///
|
||||
/// A file that does not exist yet cannot be canonicalized, so its directory is
|
||||
/// canonicalized instead and the name appended; an existing file (or link) is
|
||||
/// followed first, so a link cannot be used to write outside the grant.
|
||||
pub fn resolve_writable(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
|
||||
resolve(granted, path, Wanted::Write)
|
||||
}
|
||||
|
||||
fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf, Refusal> {
|
||||
let asked = expand_tilde(path);
|
||||
if !asked.is_absolute() {
|
||||
return Err(Refusal::Invalid(format!("{path}: use an absolute path (or ~/…)")));
|
||||
}
|
||||
let real = match wanted {
|
||||
Wanted::Read => asked.canonicalize().map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?,
|
||||
Wanted::Write => asked
|
||||
.canonicalize()
|
||||
.or_else(|_| resolve_unsaved(&asked))
|
||||
.map_err(|e| Refusal::Invalid(format!("{path}: {e}")))?,
|
||||
};
|
||||
let plain = strip_verbatim(&real);
|
||||
let cap = wanted.capability(plain.clone());
|
||||
let mut covered = false;
|
||||
for grant in granted {
|
||||
let base = match (wanted, grant) {
|
||||
(Wanted::Read, Capability::FsRead(base)) | (Wanted::Write, Capability::FsWrite(base)) => base,
|
||||
_ => continue,
|
||||
};
|
||||
// The grant is compared where it really is too: `/tmp/x` is `/private/tmp/x`
|
||||
// on macOS, and `~` is the home directory.
|
||||
let base = match expand_tilde(base).canonicalize() {
|
||||
Ok(real) => strip_verbatim(&real),
|
||||
Err(_) => strip_verbatim(&expand_tilde(base)),
|
||||
};
|
||||
if !wanted.capability(base.clone()).covers(&cap) {
|
||||
continue;
|
||||
}
|
||||
// A directory that is its own parent is a root: `/`, `C:\`.
|
||||
if Path::new(&base).parent().is_none() {
|
||||
return Err(Refusal::Invalid(format!(
|
||||
"{plain}: {}:{base} covers a whole file system; declare a directory",
|
||||
wanted.prefix()
|
||||
)));
|
||||
}
|
||||
covered = true;
|
||||
}
|
||||
if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) }
|
||||
}
|
||||
|
||||
/// Where `asked` would be created: its directory, resolved, plus its name.
|
||||
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
|
||||
let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?;
|
||||
let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?;
|
||||
let dir = parent.canonicalize().map_err(|_| "its directory does not exist".to_string())?;
|
||||
Ok(dir.join(name))
|
||||
}
|
||||
|
||||
/// `\\?\C:\…` (what `canonicalize` returns on Windows) as `C:\…`.
|
||||
fn strip_verbatim(path: &Path) -> String {
|
||||
let text = path.to_string_lossy();
|
||||
text.strip_prefix(r"\\?\").unwrap_or(&text).to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -62,6 +284,57 @@ mod tests {
|
||||
assert!(read_text(&resolve_in_root(&root, "src").unwrap()).unwrap_err().contains("not a file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_root_covers_missing_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/a.txt"), "hi").unwrap();
|
||||
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
|
||||
|
||||
assert!(inside_root(&root, "src/a.txt").unwrap());
|
||||
assert!(inside_root(&root, "src").unwrap());
|
||||
assert!(inside_root(&root, "missing.txt").unwrap());
|
||||
assert!(inside_root(&root, "missing/deep/nested.txt").unwrap());
|
||||
assert!(inside_root(&root, "missing/../src/a.txt").unwrap());
|
||||
assert!(!inside_root(&root, "../secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "missing/../../secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "missing/sub/../../../secret.txt").unwrap());
|
||||
let abs_out = tmp.path().join("secret.txt");
|
||||
assert!(!inside_root(&root, abs_out.to_str().unwrap()).unwrap());
|
||||
assert!(inside_root(&root, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_paths_are_confined_to_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
let root_real = root.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(resolve_new_in_root(&root, "src/new/a.txt").unwrap(), root_real.join("src/new/a.txt"));
|
||||
assert_eq!(resolve_new_in_root(&root, "src/../b.txt").unwrap(), root_real.join("b.txt"));
|
||||
assert!(resolve_new_in_root(&root, "../x.txt").unwrap_err().contains("outside"));
|
||||
assert!(resolve_new_in_root(&root, "src/../../x.txt").unwrap_err().contains("outside"));
|
||||
assert!(resolve_new_in_root(&root, "/etc/new.txt").is_err());
|
||||
assert!(resolve_new_in_root(&root, "").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_is_named_not_followed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("target.txt"), "t").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("target.txt"), root.join("link.txt")).unwrap();
|
||||
std::os::unix::fs::symlink(tmp.path(), root.join("out")).unwrap();
|
||||
let root_real = root.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(resolve_new_in_root(&root, "link.txt").unwrap(), root_real.join("link.txt"));
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_cannot_escape() {
|
||||
@@ -71,6 +344,11 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
|
||||
std::os::unix::fs::symlink(tmp.path().join("secret.txt"), root.join("link")).unwrap();
|
||||
assert!(resolve_in_root(&root, "link").unwrap_err().contains("outside"));
|
||||
assert!(!inside_root(&root, "link").unwrap());
|
||||
assert!(!inside_root(&root, "link/../x").unwrap());
|
||||
std::os::unix::fs::symlink(tmp.path(), root.join("dirlink")).unwrap();
|
||||
assert!(!inside_root(&root, "dirlink/secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "dirlink/missing.txt").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -80,4 +358,128 @@ mod tests {
|
||||
let p = resolve_in_root(tmp.path(), "b.bin").unwrap();
|
||||
assert!(read_text(&p).unwrap_err().contains("UTF-8"));
|
||||
}
|
||||
|
||||
/// The grant a manifest author would write for `dir`: the plain path, no
|
||||
/// `\\?\` prefix.
|
||||
fn grant_for(dir: &Path) -> Capability {
|
||||
Capability::FsRead(strip_verbatim(&dir.canonicalize().unwrap()))
|
||||
}
|
||||
|
||||
fn text_at(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||
let file = dir.join(name);
|
||||
std::fs::write(&file, body).unwrap();
|
||||
file.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_outside_every_grant_is_denied_naming_the_capability() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = text_at(tmp.path(), "app.properties", "name=YonBIP\n");
|
||||
let err = resolve_granted(&[], file.to_str().unwrap()).unwrap_err();
|
||||
match err {
|
||||
Refusal::Denied(cap) => assert!(cap.starts_with("fs.read:"), "{cap}"),
|
||||
other => panic!("expected denied, got {other:?}"),
|
||||
}
|
||||
// A grant on another tree does not cover it either.
|
||||
let elsewhere = tempfile::tempdir().unwrap();
|
||||
let err = resolve_granted(&[grant_for(elsewhere.path())], file.to_str().unwrap()).unwrap_err();
|
||||
assert!(matches!(err, Refusal::Denied(_)), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_covers_what_is_under_it_but_not_a_sibling_with_its_prefix() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let granted = tmp.path().join("seeyon");
|
||||
std::fs::create_dir_all(granted.join("conf")).unwrap();
|
||||
let file = text_at(&granted.join("conf"), "server.xml", "<server/>");
|
||||
let cap = grant_for(&granted);
|
||||
|
||||
assert_eq!(resolve_granted(std::slice::from_ref(&cap), file.to_str().unwrap()).unwrap(), file);
|
||||
// The directory itself, asked for explicitly, is covered too.
|
||||
assert!(resolve_granted(std::slice::from_ref(&cap), granted.to_str().unwrap()).is_ok());
|
||||
|
||||
// `/x/seeyon` must not cover `/x/seeyon-old/app.properties`.
|
||||
let sibling = tmp.path().join("seeyon-old");
|
||||
std::fs::create_dir_all(&sibling).unwrap();
|
||||
let other = text_at(&sibling, "app.properties", "x=1");
|
||||
assert!(matches!(resolve_granted(&[cap], other.to_str().unwrap()), Err(Refusal::Denied(_))));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_grant_declared_through_a_symlinked_directory_covers_its_files() {
|
||||
// `/tmp/tool` on macOS is `/private/tmp/tool`: the grant is resolved too.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let real = tmp.path().join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
let file = text_at(&real, "a.txt", "a");
|
||||
let link = tmp.path().join("link");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
let cap = Capability::FsRead(link.to_string_lossy().into_owned());
|
||||
assert!(resolve_granted(std::slice::from_ref(&cap), link.join("a.txt").to_str().unwrap()).is_ok());
|
||||
assert!(resolve_granted(&[cap], file.to_str().unwrap()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_granted_symlink_target_must_still_be_covered() {
|
||||
// The grant is compared with the real path, so a link inside a granted
|
||||
// tree cannot be used to read outside it.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let granted = tmp.path().join("granted");
|
||||
std::fs::create_dir_all(&granted).unwrap();
|
||||
let secret = text_at(tmp.path(), "secret.txt", "s");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&secret, granted.join("link")).unwrap();
|
||||
let cap = grant_for(&granted);
|
||||
let err = resolve_granted(&[cap], granted.join("link").to_str().unwrap()).unwrap_err();
|
||||
assert!(matches!(err, Refusal::Denied(_)), "{err:?}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Creating symlinks needs a privilege tests do not have; hard links
|
||||
// to the same file are covered by the parent directory anyway, so
|
||||
// only the textual check is exercised here.
|
||||
let _ = secret;
|
||||
assert!(resolve_granted(&[grant_for(&granted)], granted.to_str().unwrap()).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_must_be_absolute_and_exist() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cap = grant_for(tmp.path());
|
||||
let err = resolve_granted(std::slice::from_ref(&cap), "app.properties").unwrap_err();
|
||||
assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("absolute")), "{err:?}");
|
||||
let err = resolve_granted(&[cap], tmp.path().join("missing").to_str().unwrap()).unwrap_err();
|
||||
assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("no such file")), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_grant_is_refused() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = text_at(tmp.path(), "a.txt", "hi");
|
||||
#[cfg(windows)]
|
||||
let cap = Capability::FsRead(format!("{}\\", std::env::var("SystemDrive").unwrap_or_else(|_| "C:".into())));
|
||||
#[cfg(unix)]
|
||||
let cap = Capability::FsRead("/".into());
|
||||
let err = resolve_granted(&[cap], file.to_str().unwrap()).unwrap_err();
|
||||
assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("whole file system")), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tilde_starts_at_the_users_home() {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
|
||||
if home.is_none() {
|
||||
return;
|
||||
}
|
||||
let expanded = expand_tilde("~/app.properties");
|
||||
assert!(expanded.is_absolute(), "{}", expanded.display());
|
||||
assert!(expanded.starts_with(home.unwrap()));
|
||||
assert_eq!(
|
||||
expand_tilde("~"),
|
||||
PathBuf::from(std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).unwrap())
|
||||
);
|
||||
assert_eq!(expand_tilde("relative/x"), PathBuf::from("relative/x"));
|
||||
}
|
||||
}
|
||||
@@ -77,16 +77,19 @@ pub struct PickItem {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`).
|
||||
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
|
||||
/// `ui.input-multiline`, `ui.confirm`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Prompt {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
QuickPick { title: String, items: Vec<PickItem>, multiple: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Input { prompt: String, value: Option<String> },
|
||||
Input { prompt: String, value: Option<String>, multiline: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Confirm { title: String, message: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PickDirectory { title: String, initial: Option<String> },
|
||||
}
|
||||
|
||||
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
|
||||
@@ -441,8 +444,21 @@ pub struct CodeAction {
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
|
||||
/// Applied before `edits`, in one workspace edit with them.
|
||||
pub operations: Vec<FileOperation>,
|
||||
/// Opened once the action is applied.
|
||||
pub open: Option<PathBuf>,
|
||||
/// The plugin's reference: the edits come from `resolve_code_action`.
|
||||
pub data: Option<String>,
|
||||
/// Listed by the `file-actions` export, which also resolves it.
|
||||
pub file_action: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileOperation {
|
||||
Create { path: PathBuf, text: String, overwrite: bool },
|
||||
Rename { from: PathBuf, to: PathBuf },
|
||||
Delete { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -466,6 +482,16 @@ pub struct PluginHover {
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PluginInlayHint {
|
||||
pub position: ide_core::Pos,
|
||||
pub label: String,
|
||||
pub kind: String,
|
||||
pub tooltip: Option<String>,
|
||||
pub padding_left: bool,
|
||||
pub padding_right: bool,
|
||||
}
|
||||
|
||||
// ---- declarative views (wit/views.wit) ---------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -501,6 +527,8 @@ pub struct ViewListItem {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum FieldValue {
|
||||
Label(String),
|
||||
ReadonlyText { text: String, multiline: bool },
|
||||
Text(String),
|
||||
Multiline(String),
|
||||
Checkbox(bool),
|
||||
@@ -515,6 +543,14 @@ pub struct ViewField {
|
||||
pub value: FieldValue,
|
||||
}
|
||||
|
||||
/// A sortable table's order: the column and the direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableSort {
|
||||
pub column: u32,
|
||||
pub descending: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewTable {
|
||||
@@ -523,6 +559,10 @@ pub struct ViewTable {
|
||||
pub first_row: u32,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub editable: bool,
|
||||
/// The plugin opted its headers in to click-to-sort (a `table` body through `other`).
|
||||
pub sortable: bool,
|
||||
/// The sort the plugin orders by (none: no arrow yet).
|
||||
pub sort: Option<TableSort>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -545,7 +585,7 @@ pub struct View {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// A tab of the Services pane of a plugin's node (`service-provider.detail`).
|
||||
/// A tab of the Services pane of a plugin's node (`detail-provider.detail`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceTab {
|
||||
@@ -603,4 +643,28 @@ pub enum ViewEvent {
|
||||
InsertRow { before: u32 },
|
||||
DeleteRows { rows: Vec<u32> },
|
||||
Message { text: String },
|
||||
// A sortable table's header click: the column and the direction (leaves
|
||||
// as an `other` view-event, kind `sort`). See `TableSort`.
|
||||
Sort { column: u32, descending: bool },
|
||||
TableSelect { row: u32 },
|
||||
TableActivate { row: u32 },
|
||||
}
|
||||
|
||||
/// A plugin's HTTP route declaration in a project.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Endpoint {
|
||||
pub method: Option<String>,
|
||||
pub path: String,
|
||||
pub framework: String,
|
||||
pub handler: Option<String>,
|
||||
pub location: Location,
|
||||
}
|
||||
|
||||
/// A notification button that runs one of the publishing plugin's commands.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationAction {
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
@@ -49,6 +49,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
},
|
||||
@@ -77,6 +78,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
},
|
||||
@@ -105,6 +107,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
},
|
||||
@@ -112,7 +115,7 @@ pub(crate) mod views_bindings {
|
||||
}
|
||||
|
||||
use bindings::openidea::plugin::{
|
||||
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
|
||||
commit, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage, tasks,
|
||||
types as wit, ui, vcs, views, workspace,
|
||||
};
|
||||
|
||||
@@ -154,6 +157,14 @@ pub(crate) fn to_wit(e: HostError) -> wit::Error {
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_level(level: wit::NotificationLevel) -> NotificationLevel {
|
||||
match level {
|
||||
wit::NotificationLevel::Info => NotificationLevel::Info,
|
||||
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
||||
wit::NotificationLevel::Error => NotificationLevel::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
|
||||
impl From<wit::Error> for crate::runtime::GuestError {
|
||||
fn from(e: wit::Error) -> Self {
|
||||
@@ -185,6 +196,18 @@ impl StoreData {
|
||||
if self.granted.iter().any(|g| g.covers(&cap)) { Ok(()) } else { Err(wit::Error::Denied(cap.to_string())) }
|
||||
}
|
||||
|
||||
/// `path` as the real path an `fs.read:…` grant covers, or what the plugin
|
||||
/// should be told instead (`denied` naming the capability to declare).
|
||||
fn granted_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
|
||||
sandbox::resolve_granted(&self.granted, path).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// The same for `fs.write:…`: a file that does not exist yet is resolved
|
||||
/// through the directory it would be created in.
|
||||
fn writable_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
|
||||
sandbox::resolve_writable(&self.granted, path).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn client(&self) -> Option<&str> {
|
||||
self.invocation.client.as_deref()
|
||||
}
|
||||
@@ -266,19 +289,40 @@ impl StoreData {
|
||||
}
|
||||
|
||||
impl ui::Host for StoreData {
|
||||
fn notify(
|
||||
&mut self,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ui::NotificationAction>,
|
||||
) -> Fallible<()> {
|
||||
const MAX_ACTIONS: usize = 3;
|
||||
if actions.len() > MAX_ACTIONS {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
|
||||
}
|
||||
let mut checked = Vec::with_capacity(actions.len());
|
||||
for a in actions {
|
||||
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
||||
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
|
||||
}
|
||||
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
||||
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
|
||||
}
|
||||
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
|
||||
}
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn show_notification(
|
||||
&mut self,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
) -> wasmtime::Result<()> {
|
||||
let level = match level {
|
||||
wit::NotificationLevel::Info => NotificationLevel::Info,
|
||||
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
||||
wit::NotificationLevel::Error => NotificationLevel::Error,
|
||||
};
|
||||
let max = crate::runtime::MAX_TITLE;
|
||||
self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10));
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
// A plain notification has nothing the host could refuse.
|
||||
let _ = self.host.notify(self.client(), notification_level(level), title, body, Vec::new());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -350,12 +394,35 @@ impl ui::Host for StoreData {
|
||||
Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next())))
|
||||
}
|
||||
|
||||
fn pick_directory(&mut self, title: String, initial: Option<String>) -> Fallible<Option<String>> {
|
||||
if let Some(path) = &initial
|
||||
&& let Err(error) = validate_ui_path(path)
|
||||
{
|
||||
return Ok(Err(error));
|
||||
}
|
||||
let answer = self.ask(Prompt::PickDirectory { title: clip(title, MAX_TITLE), initial });
|
||||
Ok(answer.and_then(|answer| {
|
||||
let path = answer.and_then(|items| items.into_iter().next());
|
||||
if let Some(path) = &path {
|
||||
validate_ui_path(path)?;
|
||||
}
|
||||
Ok(path)
|
||||
}))
|
||||
}
|
||||
|
||||
fn pick_many(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<Vec<String>>> {
|
||||
Ok(self.pick_prompt(title, items, true))
|
||||
}
|
||||
|
||||
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
||||
let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) };
|
||||
let prompt =
|
||||
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: false };
|
||||
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
||||
}
|
||||
|
||||
fn input_multiline(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
||||
let prompt =
|
||||
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: true };
|
||||
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
||||
}
|
||||
|
||||
@@ -363,6 +430,10 @@ impl ui::Host for StoreData {
|
||||
let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) };
|
||||
Ok(self.ask(prompt).map(|answer| answer.is_some()))
|
||||
}
|
||||
|
||||
fn locale(&mut self) -> wasmtime::Result<String> {
|
||||
Ok(self.host.locale())
|
||||
}
|
||||
}
|
||||
|
||||
impl storage::Host for StoreData {
|
||||
@@ -396,6 +467,18 @@ impl storage::Host for StoreData {
|
||||
}
|
||||
|
||||
impl workspace::Host for StoreData {
|
||||
fn open_project(&mut self, path: String, new_window: bool) -> Fallible<()> {
|
||||
if let Err(error) = self.require_interactive().and_then(|_| validate_ui_path(&path)) {
|
||||
return Ok(Err(error));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn project_of(&mut self, path: String) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
|
||||
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
|
||||
}
|
||||
|
||||
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -424,7 +507,10 @@ impl workspace::HostProject for StoreData {
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
if let Some(text) = self.host.buffer_text(&real) {
|
||||
return Ok(Ok(text));
|
||||
@@ -432,6 +518,39 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
match sandbox::inside_root(&root, &path) {
|
||||
Err(e) => Ok(Err(wit::Error::Invalid(e))),
|
||||
Ok(false) => {
|
||||
Ok(Err(wit::Error::Invalid(format!("{path}: access outside the project root is not allowed"))))
|
||||
}
|
||||
Ok(true) => {
|
||||
let requested = Path::new(&path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
Ok(Ok(joined.exists()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stat(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<workspace::FileStat> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
Ok(file_stat(&real, &path))
|
||||
}
|
||||
|
||||
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -440,6 +559,27 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn write_file(&mut self, this: Resource<ProjectRes>, path: String, text: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if text.len() > sandbox::MAX_WRITE_BYTES {
|
||||
return Ok(Err(too_much_to_write(&path)));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_new_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
// The directories it goes in are created, like a new file in the project view.
|
||||
if let Some(parent) = real.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
return Ok(Err(wit::Error::Failed(format!("{path}: {e}"))));
|
||||
}
|
||||
Ok(self.host.write_text_file(&real, &text).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<ProjectRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(rep)?;
|
||||
Ok(())
|
||||
@@ -640,14 +780,19 @@ impl events::Host for StoreData {
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
self.host.subscribe(kind, true);
|
||||
self.host.subscribe(kind);
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
|
||||
if let Some(kind) = EventKind::parse(&kind) {
|
||||
self.host.subscribe(kind, false);
|
||||
fn subscribe_paths(&mut self, kind: String, globs: Vec<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Events) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
|
||||
self.host.unsubscribe(&kind);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -864,6 +1009,123 @@ impl settings::Host for StoreData {
|
||||
|
||||
impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
|
||||
|
||||
fn file_kind(t: std::fs::FileType) -> workspace::FileKind {
|
||||
if t.is_file() {
|
||||
workspace::FileKind::File
|
||||
} else if t.is_dir() {
|
||||
workspace::FileKind::Directory
|
||||
} else if t.is_symlink() {
|
||||
workspace::FileKind::Symlink
|
||||
} else {
|
||||
workspace::FileKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// `project.stat` and `fs.stat`: the metadata of `real`, named `shown` in errors.
|
||||
fn file_stat(real: &Path, shown: &str) -> Result<workspace::FileStat, wit::Error> {
|
||||
let meta = std::fs::symlink_metadata(real).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => wit::Error::NotFound(format!("{shown}: no such file")),
|
||||
_ => wit::Error::Failed(e.to_string()),
|
||||
})?;
|
||||
let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
|
||||
Ok(workspace::FileStat { kind: file_kind(meta.file_type()), size: meta.len(), modified })
|
||||
}
|
||||
|
||||
/// A refused `fs` call as the plugin sees it: `denied` carries the capability
|
||||
/// string, so the plugin can say exactly what its manifest lacks.
|
||||
impl From<sandbox::Refusal> for wit::Error {
|
||||
fn from(refusal: sandbox::Refusal) -> Self {
|
||||
match refusal {
|
||||
sandbox::Refusal::Denied(cap) => Self::Denied(cap),
|
||||
sandbox::Refusal::Invalid(message) => Self::Invalid(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files outside the open projects, gated by `fs.read:<path>` and
|
||||
/// `fs.write:<path>` grants.
|
||||
impl fs::Host for StoreData {
|
||||
fn read_file(&mut self, path: String) -> Fallible<String> {
|
||||
let real = match self.granted_file(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
Ok(self.host.read_text_file(&real, sandbox::MAX_READ_BYTES as usize).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, path: String) -> Fallible<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let real = match self.granted_file(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let max = sandbox::MAX_READ_BYTES;
|
||||
let read = std::fs::File::open(&real).and_then(|file| {
|
||||
let mut bytes = Vec::new();
|
||||
// One byte over: a file that grew past the limit is refused, not truncated.
|
||||
file.take(max + 1).read_to_end(&mut bytes).map(|_| bytes)
|
||||
});
|
||||
Ok(match read {
|
||||
Ok(bytes) if bytes.len() as u64 > max => {
|
||||
Err(wit::Error::Invalid(format!("{path}: larger than {} MiB", max >> 20)))
|
||||
}
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
Err(wit::Error::NotFound(format!("{path}: no such file")))
|
||||
}
|
||||
Err(e) => Err(wit::Error::Failed(format!("{path}: {e}"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn stat(&mut self, path: String) -> Fallible<workspace::FileStat> {
|
||||
Ok(self.granted_file(&path).and_then(|real| file_stat(&real, &path)))
|
||||
}
|
||||
|
||||
fn read_dir(&mut self, path: String) -> Fallible<Vec<fs::DirEntry>> {
|
||||
let real = match self.granted_file(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let read = match std::fs::read_dir(&real) {
|
||||
Ok(read) => read,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Err(wit::Error::NotFound(format!("{path}: no such directory"))));
|
||||
}
|
||||
Err(_) if real.is_file() => return Ok(Err(wit::Error::Invalid(format!("{path}: not a directory")))),
|
||||
Err(e) => return Ok(Err(wit::Error::Failed(format!("{path}: {e}")))),
|
||||
};
|
||||
let mut entries: Vec<fs::DirEntry> = read
|
||||
.flatten()
|
||||
.take(sandbox::MAX_LIST_ENTRIES)
|
||||
.map(|e| fs::DirEntry {
|
||||
name: e.file_name().to_string_lossy().into_owned(),
|
||||
kind: e.file_type().map_or(workspace::FileKind::Other, file_kind),
|
||||
})
|
||||
.collect();
|
||||
// Sorted, so what a plugin picks doesn't depend on the disk's order.
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(Ok(entries))
|
||||
}
|
||||
|
||||
fn write_file(&mut self, path: String, text: String) -> Fallible<()> {
|
||||
if text.len() > sandbox::MAX_WRITE_BYTES {
|
||||
return Ok(Err(too_much_to_write(&path)));
|
||||
}
|
||||
Ok(self.writable_file(&path).and_then(|real| self.host.write_text_file(&real, &text).map_err(to_wit)))
|
||||
}
|
||||
|
||||
fn write_bytes(&mut self, path: String, bytes: Vec<u8>) -> Fallible<()> {
|
||||
if bytes.len() > sandbox::MAX_WRITE_BYTES {
|
||||
return Ok(Err(too_much_to_write(&path)));
|
||||
}
|
||||
Ok(self.writable_file(&path).and_then(|real| self.host.write_bytes_file(&real, &bytes).map_err(to_wit)))
|
||||
}
|
||||
}
|
||||
|
||||
fn too_much_to_write(path: &str) -> wit::Error {
|
||||
wit::Error::Invalid(format!("{path}: more than {} MiB to write", sandbox::MAX_WRITE_BYTES >> 20))
|
||||
}
|
||||
|
||||
/// Largest message between a plugin and its view's frame.
|
||||
const MAX_VIEW_MESSAGE: usize = 1 << 20;
|
||||
|
||||
@@ -874,10 +1136,10 @@ pub(crate) mod indexing_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `service-provider` export.
|
||||
pub(crate) mod services_bindings {
|
||||
/// The optional `detail-provider` export.
|
||||
pub(crate) mod details_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "services", path: "../../wit",
|
||||
world: "details", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
@@ -914,3 +1176,59 @@ pub(crate) mod hover_bindings {
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod inlay_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "inlay-hints", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod endpoints_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "endpoints", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `file-actions` export.
|
||||
pub(crate) mod file_actions_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "file-actions-provider", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
/// Paths belong to the UI machine, which can have a different OS than the plugin host.
|
||||
fn validate_ui_path(path: &str) -> Result<(), wit::Error> {
|
||||
let b = path.as_bytes();
|
||||
let absolute = path.starts_with('/')
|
||||
|| path.starts_with("\\\\")
|
||||
|| (b.first().is_some_and(u8::is_ascii_alphabetic)
|
||||
&& b.get(1) == Some(&b':')
|
||||
&& matches!(b.get(2), Some(b'/' | b'\\')));
|
||||
if !absolute || path.len() > 8192 || path.chars().any(char::is_control) {
|
||||
return Err(wit::Error::Invalid("expected an absolute UI directory path of at most 8192 bytes".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod project_ui_tests {
|
||||
use super::validate_ui_path;
|
||||
#[test]
|
||||
fn validates_ui_paths_without_using_the_plugin_hosts_os() {
|
||||
for path in ["/work/repo", "C:/work/repo", r"C:\work\repo", r"\\server\share\repo"] {
|
||||
assert!(validate_ui_path(path).is_ok(), "{path:?}");
|
||||
}
|
||||
for path in ["repo", "C:repo", "/bad\npath", ""] {
|
||||
assert!(validate_ui_path(path).is_err(), "{path:?}");
|
||||
}
|
||||
assert!(validate_ui_path(&format!("/{}", "x".repeat(8192))).is_err());
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ fn components() -> Vec<PathBuf> {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
|
||||
];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
|
||||
95de177eb6e9db39e0a9d5acb86ac1b0f19d91c954590135748774ba83638b53
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-endpoints"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-endpoints"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
0f9992726659db0400c43192a623ecf97977766e0d7282f99e5ba800fdf247c1
|
||||
@@ -0,0 +1,13 @@
|
||||
id = "test.endpoints"
|
||||
name = "Endpoints fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
capabilities.required = ["project.read"]
|
||||
|
||||
[[contributes.endpoints]]
|
||||
files = ["routes.txt"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "crash"
|
||||
title = "Crash endpoint fixture"
|
||||
@@ -0,0 +1,32 @@
|
||||
use openidea_plugin::endpoints::{Endpoint, EndpointsProvider};
|
||||
use openidea_plugin::{Error, Invocation, Location, Plugin, Position, Range, register_plugin};
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id == "crash" {
|
||||
panic!("endpoint fixture crash");
|
||||
}
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
impl EndpointsProvider for Fixture {
|
||||
fn endpoints(&mut self, ctx: &Invocation) -> Result<Vec<Endpoint>, Error> {
|
||||
let project = ctx.project().ok_or("no project invocation")?;
|
||||
let text = project.read_file("routes.txt")?;
|
||||
if text.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Ok(vec![Endpoint {
|
||||
method: None,
|
||||
path: text.lines().next().unwrap().into(),
|
||||
framework: "Fixture".into(),
|
||||
handler: Some("Routes#show".into()),
|
||||
location: Location {
|
||||
path: format!("{}/routes.txt", project.root()?),
|
||||
range: Range { start: Position::new(0, 0), end: Position::new(0, 0) },
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
register_plugin!(Fixture, endpoints);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
|
||||
7a77d431efd04f0fd5b864cd30216e655b67f126d8a4dd4506dcf805a3454076
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
|
||||
//! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md).
|
||||
|
||||
use openidea_plugin::services::{Pane, ProcessLog, Services, Tab};
|
||||
use openidea_plugin::details::{Details, Pane, ProcessLog, Tab};
|
||||
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin};
|
||||
use std::collections::BTreeSet;
|
||||
@@ -98,14 +98,14 @@ impl Views for Hello {
|
||||
}
|
||||
|
||||
/// As a services view (tests): a node's key is the `sh -c` script its Log tab runs.
|
||||
impl Services for Hello {
|
||||
impl Details for Hello {
|
||||
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
|
||||
let log = ProcessLog::new("sh", &["-c", key]).toggle("named", "Named", &["named"]);
|
||||
Ok(vec![Tab::new("log", "Log", log), Tab::new("info", "Info", Pane::Text(format!("{view}: {key}")))])
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Hello, views, services);
|
||||
register_plugin!(Hello, views, details);
|
||||
|
||||
/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock).
|
||||
fn today() -> String {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
|
||||
5cf52999f65c757d0eb793934c28ae6d9f453053514c2b4c8058d6e70384eb9a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
|
||||
07839dc01f3d528a81969703271d6c7ad1397b142bdc3aff68d7384249a15818
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-inlay-hints"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-inlay-hints"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Inlay hints test fixture
|
||||
|
||||
After every `->name()` call in a PHP document that declares
|
||||
`function name(): Type`, shows `: Type` as a type hint.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_inlay_hints` to exercise the
|
||||
optional `inlay-hints-provider` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
7ef04eaa3f6d506291c89877de26a11b6a27df900c223ce53bf1749523839ff6
|
||||
@@ -0,0 +1,9 @@
|
||||
id = "test.inlay-hints"
|
||||
name = "Inlay hints fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["inlay-hints"]
|
||||
languages = ["php"]
|
||||
@@ -0,0 +1,47 @@
|
||||
use openidea_plugin::inlay_hints::{Document, InlayHint, InlayHints, InlayKind};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, Range, register_plugin, text};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl InlayHints for Fixture {
|
||||
fn inlay_hints(&mut self, doc: &Document, _range: Range) -> Vec<InlayHint> {
|
||||
let text = doc.text.as_str();
|
||||
let mut out = Vec::new();
|
||||
let mut rest = 0;
|
||||
while let Some(i) = text[rest..].find("->") {
|
||||
let start = rest + i + 2;
|
||||
let end = text[start..]
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.map_or(text.len(), |n| start + n);
|
||||
rest = end;
|
||||
if end == start || !text[end..].starts_with("()") {
|
||||
continue;
|
||||
}
|
||||
let name = &text[start..end];
|
||||
let declared = format!("function {name}(): ");
|
||||
let Some(at) = text.find(&declared) else { continue };
|
||||
let ty: String = text[at + declared.len()..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '\\' | '?'))
|
||||
.collect();
|
||||
if ty.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(
|
||||
InlayHint::new(text::position(text, end + 2), format!(": {ty}"))
|
||||
.kind(InlayKind::Type)
|
||||
.tooltip(format!("{name}() returns {ty}")),
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, inlay_hints);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
|
||||
bd79d7641d84e919a83ea082b00eca78d4d1427e1e3345fba7d289b06862edde
|
||||
@@ -38,3 +38,18 @@ category = "Jobs"
|
||||
[contributes.settings]
|
||||
title = "Jobs"
|
||||
schema = "settings.schema.json"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "notify-actions"
|
||||
title = "Jobs: Show Notification Actions"
|
||||
category = "Jobs"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "pick-directory"
|
||||
title = "Pick directory"
|
||||
[[contributes.commands]]
|
||||
id = "open-project"
|
||||
title = "Open project"
|
||||
[[contributes.commands]]
|
||||
id = "open-project-new"
|
||||
title = "Open project in new window"
|
||||
@@ -53,6 +53,28 @@ impl Plugin for Jobs {
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
"notify-actions" => {
|
||||
if arg == Some("plain") {
|
||||
host::notify(NotificationLevel::Info, "Choose an action", "A plugin notification");
|
||||
return Ok(None);
|
||||
}
|
||||
use openidea_plugin::NotificationAction;
|
||||
let command = if arg == Some("unknown") { "not-declared" } else { "context" };
|
||||
let count = if arg == Some("four") { 4 } else { 3 };
|
||||
let actions = (0..count)
|
||||
.map(|i| {
|
||||
NotificationAction::new(format!("Action {}", i + 1), command)
|
||||
.argument(format!("notification-action={}\nvalue", i + 1))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
host::notify_with_actions(
|
||||
NotificationLevel::Info,
|
||||
"Choose an action",
|
||||
"A plugin notification",
|
||||
&actions,
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
"copy" => {
|
||||
host::copy_to_clipboard(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
@@ -61,6 +83,11 @@ impl Plugin for Jobs {
|
||||
host::open_url(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
}
|
||||
"pick-directory" => Ok(Some(host::pick_directory("Clone target", arg)?.unwrap_or_else(|| "none".into()))),
|
||||
"open-project" | "open-project-new" => {
|
||||
host::open_project(arg.unwrap_or_default(), id == "open-project-new")?;
|
||||
Ok(None)
|
||||
}
|
||||
"pick" | "pick-many" => {
|
||||
let long_key = "k".repeat(300);
|
||||
let items = [
|
||||
@@ -75,6 +102,7 @@ impl Plugin for Jobs {
|
||||
Ok(Some(picked.map_or("none".to_owned(), |keys| keys.join(","))))
|
||||
}
|
||||
"input" => Ok(Some(host::input("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
|
||||
"input-multiline" => Ok(Some(host::input_multiline("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
|
||||
"confirm" => Ok(Some(host::confirm("Delete", "Really?")?.to_string())),
|
||||
"reveal" => host::reveal_view(arg.unwrap_or_default()).map(|()| None),
|
||||
"selection" => {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
|
||||
95835b883dec1cf9b053e455533ed0329b6fb1b9270832865b7d8fd05fbaff5a
|
||||
@@ -33,6 +33,22 @@ title = "read"
|
||||
id = "root"
|
||||
title = "root"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "fsread"
|
||||
title = "fsread"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "read-dir"
|
||||
title = "List a directory outside the project"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "fswrite"
|
||||
title = "fswrite"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "projwrite"
|
||||
title = "projwrite"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "insert"
|
||||
title = "insert"
|
||||
|
||||
@@ -7,6 +7,7 @@ wit_bindgen::generate!({
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.3.0;
|
||||
export openidea:plugin/providers@1.3.0;
|
||||
export openidea:plugin/file-actions@1.3.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
@@ -62,6 +63,35 @@ impl commands::Guest for Misbehave {
|
||||
project.read_file(&arg.unwrap_or_default()).map(Some)
|
||||
}
|
||||
"root" => ctx.project().map(|p| p.root()).transpose(),
|
||||
// The `fs.read` grants: the text of `arg`, or the refusal naming what it needs.
|
||||
"fsread" => openidea::plugin::fs::read_file(&arg.unwrap_or_default()).map(Some),
|
||||
// The `fs.read-dir` listing: "name kind" lines.
|
||||
"read-dir" => {
|
||||
use openidea::plugin::workspace::FileKind;
|
||||
let entries = openidea::plugin::fs::read_dir(&arg.unwrap_or_default())?;
|
||||
let lines: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let kind = match e.kind {
|
||||
FileKind::File => "file",
|
||||
FileKind::Directory => "dir",
|
||||
FileKind::Symlink => "link",
|
||||
FileKind::Other => "other",
|
||||
};
|
||||
format!("{kind} {}", e.name)
|
||||
})
|
||||
.collect();
|
||||
Ok(Some(lines.join("\n")))
|
||||
}
|
||||
// The `fs.write` grant: replaces the file at `arg`.
|
||||
"fswrite" => {
|
||||
openidea::plugin::fs::write_file(&arg.unwrap_or_default(), "written by the plugin\n").map(|()| None)
|
||||
}
|
||||
// The `project.write` grant: writes `arg` inside the project.
|
||||
"projwrite" => {
|
||||
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
|
||||
project.write_file(&arg.unwrap_or_default(), "written inside the project\n").map(|()| None)
|
||||
}
|
||||
// `editor.write` is not granted: an error, not a trap.
|
||||
"insert" => {
|
||||
let editor = ctx.editor().ok_or_else(|| failed("no editor"))?;
|
||||
@@ -189,4 +219,64 @@ impl providers::Guest for Misbehave {
|
||||
}
|
||||
}
|
||||
|
||||
/// `files`: one action that creates, renames and deletes files, then opens
|
||||
/// one, and one listed with `data` only, whose file `resolve` creates.
|
||||
impl exports::openidea::plugin::file_actions::Guest for Misbehave {
|
||||
fn code_actions(
|
||||
doc: exports::openidea::plugin::file_actions::Document,
|
||||
_selection: types::Range,
|
||||
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
|
||||
if doc.text != "files" {
|
||||
return Vec::new();
|
||||
}
|
||||
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir).to_owned();
|
||||
vec![
|
||||
FileAction {
|
||||
title: "Reorganise".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: vec![
|
||||
FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/created.txt"),
|
||||
text: "hello".into(),
|
||||
overwrite: false,
|
||||
}),
|
||||
FileOperation::Rename(RenameFile {
|
||||
path: format!("{dir}/old.txt"),
|
||||
new_path: format!("{dir}/renamed.txt"),
|
||||
}),
|
||||
FileOperation::Delete(DeleteFile { path: format!("{dir}/gone.txt") }),
|
||||
],
|
||||
edits: Vec::new(),
|
||||
open: Some(format!("{dir}/created.txt")),
|
||||
data: None,
|
||||
},
|
||||
FileAction {
|
||||
title: "Create later".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: Vec::new(),
|
||||
edits: Vec::new(),
|
||||
open: None,
|
||||
data: Some(dir),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
_doc: exports::openidea::plugin::file_actions::Document,
|
||||
action: exports::openidea::plugin::file_actions::FileAction,
|
||||
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
|
||||
let dir = action.data.clone().ok_or_else(|| types::Error::Invalid("no data".into()))?;
|
||||
let operations = vec![FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/later.txt"),
|
||||
text: "later".into(),
|
||||
overwrite: false,
|
||||
})];
|
||||
Ok(exports::openidea::plugin::file_actions::FileAction { operations, data: None, ..action })
|
||||
}
|
||||
}
|
||||
|
||||
export!(Misbehave);
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-path-events"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-path-events"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
b6c96915f0b0668134bc1b197ad20545f85bd2cb0941a0e83a9e06b688c96434
|
||||
@@ -0,0 +1,34 @@
|
||||
id = "test.path-events"
|
||||
name = "Path event fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["storage"]
|
||||
optional = ["events", "project.read"]
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["hover"]
|
||||
languages = ["text", "plaintext"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "project"
|
||||
title = "project"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "filter"
|
||||
title = "filter"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "all"
|
||||
title = "all"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "off"
|
||||
title = "off"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "count"
|
||||
title = "count"
|
||||
@@ -0,0 +1,47 @@
|
||||
use openidea_plugin::hover::{Document, Hover, HoverProvider};
|
||||
use openidea_plugin::{Context, Error, Event, EventKind, Invocation, Plugin, Position, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn activate(&mut self, _: &Context) -> Result<(), Error> {
|
||||
// Persist the chosen filter so reactivation does not overwrite it.
|
||||
let glob = host::storage::get("filter")?.unwrap_or_else(|| "config/*.yaml".into());
|
||||
let globs: Vec<_> = glob.lines().collect();
|
||||
let _ = host::subscribe_paths("file-opened", &globs);
|
||||
let _ = host::subscribe_paths("file-saved", &["config/*.yaml"]);
|
||||
Ok(())
|
||||
}
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"project" => host::project_of(arg.unwrap_or_default()).map(|p| p.root()).transpose(),
|
||||
"filter" => {
|
||||
let glob = arg.unwrap_or_default();
|
||||
host::subscribe_paths("file-opened", &glob.lines().collect::<Vec<_>>())?;
|
||||
host::storage::set("filter", glob)?;
|
||||
Ok(None)
|
||||
}
|
||||
"all" => {
|
||||
host::subscribe(EventKind::FileOpened)?;
|
||||
Ok(None)
|
||||
}
|
||||
"off" => {
|
||||
host::unsubscribe(EventKind::FileOpened);
|
||||
Ok(None)
|
||||
}
|
||||
"count" => Ok(host::storage::get("count")?),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
fn handle_events(&mut self, events: &[Event], _: &Invocation) {
|
||||
let count = host::storage::get("count").ok().flatten().and_then(|n| n.parse::<usize>().ok()).unwrap_or(0);
|
||||
host::storage::set("count", &(count + events.len()).to_string()).unwrap();
|
||||
}
|
||||
}
|
||||
impl HoverProvider for Fixture {
|
||||
fn hover(&mut self, doc: &Document, _: Position) -> Option<Hover> {
|
||||
Some(Hover::new(host::project_of(&doc.path)?.root().ok()?))
|
||||
}
|
||||
}
|
||||
register_plugin!(Fixture, hover);
|
||||
Binary file not shown.
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user