mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:20:15 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90d3a7a8e | ||
|
|
115d789091 | ||
|
|
fb92a82101 | ||
|
|
7829bee55c | ||
|
|
13b590f2c7 | ||
|
|
8d5504b376 | ||
|
|
60b19b2ffb | ||
|
|
73b3a99699 | ||
|
|
95843ea12d | ||
|
|
3dac5b8bdc | ||
|
|
fd8b4ff14c | ||
|
|
f70eb13f75 | ||
|
|
337bf4b1ca | ||
|
|
047cac209b | ||
|
|
c5647a5934 | ||
|
|
1f2d86f793 | ||
|
|
998b478de3 |
No files matched your search
@@ -22,6 +22,8 @@ env:
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# The schedule is for this repository's release; forks can still run it by hand.
|
||||
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
|
||||
Generated
+144
-1
@@ -60,6 +60,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -616,6 +630,12 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borrow-or-share"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
@@ -690,6 +710,12 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
@@ -2030,6 +2056,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
@@ -2187,6 +2222,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faster-hex"
|
||||
version = "0.10.1"
|
||||
@@ -2287,6 +2333,17 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5"
|
||||
dependencies = [
|
||||
"borrow-or-share",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -2346,6 +2403,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fraction"
|
||||
version = "0.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
@@ -4256,7 +4323,9 @@ name = "ide-db"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
@@ -4274,6 +4343,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
@@ -4380,6 +4450,7 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"parking_lot",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
@@ -4587,6 +4658,7 @@ name = "ide-plugin"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"parking_lot",
|
||||
@@ -4598,6 +4670,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
@@ -4620,6 +4693,7 @@ dependencies = [
|
||||
name = "ide-remote"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -5287,6 +5361,32 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64 0.22.1",
|
||||
"bytecount",
|
||||
"email_address",
|
||||
"fancy-regex",
|
||||
"fraction",
|
||||
"idna",
|
||||
"itoa",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-protocol"
|
||||
version = "2.0.3"
|
||||
@@ -5520,7 +5620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"fluent-uri",
|
||||
"fluent-uri 0.1.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -5862,6 +5962,12 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@@ -6539,6 +6645,12 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0"
|
||||
@@ -7306,6 +7418,20 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri 0.3.2",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regalloc2"
|
||||
version = "0.15.2"
|
||||
@@ -10496,6 +10622,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"uuid",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -10520,6 +10657,12 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vsimd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
|
||||
+54
-35
@@ -100,7 +100,7 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session command lookup per provider, including failed attempts.
|
||||
/// One pre-session lookup of commands and settings per provider, including failed attempts.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
@@ -153,7 +153,7 @@ impl Shared {
|
||||
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_commands(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
@@ -163,14 +163,14 @@ impl Shared {
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(commands) =
|
||||
native::claude::commands(spec.clone(), shared.paths.project_root.clone()).await
|
||||
if let Ok((commands, options)) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": options }));
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if shared.known_commands(&spec.id).is_empty() {
|
||||
shared.remember(&spec.id, None, Some(&commands), None);
|
||||
}
|
||||
shared.remember_discovered(&spec.id, &options, &commands);
|
||||
shared.emit(AgentEvent::Providers);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -187,6 +187,10 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_config(&self, provider: &str) -> bool {
|
||||
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -199,37 +203,52 @@ impl Shared {
|
||||
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 {
|
||||
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();
|
||||
self.remember_inner(provider, config, commands, model, false);
|
||||
}
|
||||
|
||||
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
|
||||
}
|
||||
|
||||
fn remember_inner(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
discovered: bool,
|
||||
) {
|
||||
// Keep the update and its disk write ordered; a session report wins over discovery.
|
||||
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(|_| !discovered || known.config.is_empty()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) =
|
||||
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
|
||||
{
|
||||
known.commands.insert(root, c.to_vec());
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
serde_json::to_vec_pretty(&*cache)
|
||||
};
|
||||
if let Ok(bytes) = snapshot
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) = commands.filter(|c| {
|
||||
!c.is_empty()
|
||||
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
|
||||
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
|
||||
}) {
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
|
||||
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
@@ -458,10 +477,10 @@ impl AgentManager {
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& offered.is_empty()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider))
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_commands(spec).await;
|
||||
self.shared.fetch_settings(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
|
||||
@@ -69,8 +69,9 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// Discover commands without a prompt, ACP session, or pending session file.
|
||||
pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Value>, String> {
|
||||
/// Discover the slash commands and the model, mode and effort options without a prompt,
|
||||
/// ACP session, or pending session file.
|
||||
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
@@ -79,28 +80,52 @@ pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Val
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
let request = json!({ "type": "control_request", "request_id": "commands",
|
||||
"request": { "subtype": "initialize", "perTaskStopAffordance": true } });
|
||||
io.stdin.write_all(format!("{request}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
io.stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
let mut lines = BufReader::new(io.stdout).lines();
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == "commands" {
|
||||
let response = &msg["response"];
|
||||
return if response["subtype"] == "success" {
|
||||
Ok(Settings::from_cli(&response["response"], &Value::Null).commands)
|
||||
} else {
|
||||
Err("Claude Code did not initialize".into())
|
||||
};
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
let init = control(
|
||||
&mut io.stdin,
|
||||
&mut lines,
|
||||
"commands",
|
||||
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
|
||||
)
|
||||
.await?
|
||||
.ok_or("Claude Code did not initialize")?;
|
||||
// The effort and model the user configured; without them the options show the defaults.
|
||||
let applied = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let settings = Settings::from_cli(&init, &applied);
|
||||
Ok((settings.commands.clone(), settings.options()))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
|
||||
async fn control(
|
||||
stdin: &mut (impl AsyncWriteExt + Unpin),
|
||||
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
|
||||
id: &str,
|
||||
request: Value,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let line = json!({ "type": "control_request", "request_id": id, "request": request });
|
||||
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
|
||||
let response = &msg["response"];
|
||||
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
|
||||
+112
-10
@@ -371,7 +371,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for outcome in ["success", "error", "exit", "timeout", "missing"] {
|
||||
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let script = root.join("fake-claude");
|
||||
@@ -380,6 +380,7 @@ async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
r#"#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys, time
|
||||
root = pathlib.Path.cwd()
|
||||
(root / 'pid').write_text(str(os.getpid()))
|
||||
with (root / 'launches').open('a') as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + '\n')
|
||||
request = json.loads(sys.stdin.readline())
|
||||
@@ -391,16 +392,34 @@ if outcome == 'exit':
|
||||
if outcome == 'timeout':
|
||||
time.sleep(60)
|
||||
response = {'subtype': outcome, 'request_id': request['request_id']}
|
||||
if outcome == 'success':
|
||||
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
|
||||
response['subtype'] = 'success'
|
||||
response['response'] = {'commands': [
|
||||
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
|
||||
'models': [
|
||||
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
|
||||
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
|
||||
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
|
||||
if outcome == 'empty':
|
||||
response['response'] = {}
|
||||
else:
|
||||
response['error'] = 'Authentication required'
|
||||
if outcome == 'session_report':
|
||||
(root / 'initialized').touch()
|
||||
while not (root / 'continue').exists():
|
||||
time.sleep(0.01)
|
||||
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
|
||||
for line in sys.stdin:
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(line)
|
||||
request = json.loads(line)
|
||||
if request['request']['subtype'] == 'get_settings':
|
||||
if outcome == 'settings_timeout':
|
||||
time.sleep(60)
|
||||
reply = {'subtype': 'success', 'request_id': request['request_id'],
|
||||
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
|
||||
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -417,19 +436,59 @@ for line in sys.stdin:
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
|
||||
let concurrent = async {
|
||||
if outcome == "session_report" {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !root.join("initialized").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let reported = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
|
||||
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
std::fs::write(root.join("continue"), "").unwrap();
|
||||
}
|
||||
mgr.commands("claude", None).await
|
||||
};
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
|
||||
let first = first.unwrap();
|
||||
assert_eq!(first, concurrent.unwrap());
|
||||
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
|
||||
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
|
||||
if outcome == "success" {
|
||||
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
|
||||
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
|
||||
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
|
||||
let restored = manager(root, Arc::new(FakeAgent::default()));
|
||||
assert_eq!(first, restored.commands("claude", None).await.unwrap());
|
||||
// The same handshake lists the models, so the composer has them before any task.
|
||||
for mgr in [&mgr, &restored] {
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
|
||||
assert_eq!(model.current.as_deref(), Some("default"));
|
||||
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
|
||||
assert_eq!(values, ["default", "custom-opus"]);
|
||||
let effort =
|
||||
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
|
||||
assert_eq!(
|
||||
effort.unwrap().current.as_deref(),
|
||||
Some(match outcome {
|
||||
"settings_timeout" => "default",
|
||||
"session_report" => "low",
|
||||
_ => "high",
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(names, ["triage"], "{outcome}");
|
||||
assert!(!root.join("models.json").exists());
|
||||
assert_eq!(root.join("models.json").exists(), outcome == "empty");
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
|
||||
}
|
||||
assert!(mgr.tasks.read().is_empty());
|
||||
assert!(!root.join("store").exists());
|
||||
@@ -439,10 +498,25 @@ for line in sys.stdin:
|
||||
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
|
||||
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
|
||||
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
|
||||
assert_eq!(requests.lines().count(), 1, "only initialize, no prompt or other control request");
|
||||
let request: Value = serde_json::from_str(requests.trim()).unwrap();
|
||||
assert_eq!(request["type"], "control_request");
|
||||
assert_eq!(request["request"]["subtype"], "initialize");
|
||||
let subtypes: Vec<String> = requests
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
// The handshake and the settings read: no prompt or other control request.
|
||||
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
|
||||
&["initialize", "get_settings"]
|
||||
} else {
|
||||
&["initialize"]
|
||||
};
|
||||
assert_eq!(subtypes, expected, "{outcome}");
|
||||
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("discovery process must exit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2358,3 +2432,31 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
|
||||
"args":["--mcp-stdio","--project","/project"],"env":[]})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
for discovery_first in [false, true] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let options = |model: &str, effort: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
|
||||
"options": [{ "value": "saved" }, { "value": "discovered" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
|
||||
"options": [{ "value": "low" }, { "value": "high" }] }
|
||||
] }))
|
||||
};
|
||||
let saved = options("saved", "low");
|
||||
let discovered = options("discovered", "high");
|
||||
if discovery_first {
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
}
|
||||
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
for mgr in [&mgr, &restored] {
|
||||
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
|
||||
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,4 @@ tempfile.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_System_IO"] }
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
|
||||
@@ -20,6 +20,8 @@ pub enum McpUiRequest {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum IdeEvent {
|
||||
/// Project data source settings changed; reload the Database tree.
|
||||
DbSourcesChanged,
|
||||
McpRequest {
|
||||
id: u32,
|
||||
request: McpUiRequest,
|
||||
|
||||
+128
-10
@@ -4,10 +4,12 @@ use std::collections::VecDeque;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(any(not(unix), test))]
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WRITES: usize = 1024;
|
||||
#[cfg(any(not(unix), test))]
|
||||
const RETRY: Duration = Duration::from_millis(4);
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -28,10 +30,11 @@ pub struct InputWriter {
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// Unix writers must first be made nonblocking with `nonblocking`.
|
||||
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
|
||||
pub fn new(
|
||||
name: String,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
|
||||
on_error: impl FnOnce(io::Error) + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
@@ -74,11 +77,19 @@ impl InputWriter {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
#[cfg(unix)]
|
||||
match wait_writable() {
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
result => result?,
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
@@ -168,14 +179,46 @@ impl Drop for InputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// PTY clones share file status flags, so readers must also handle WouldBlock.
|
||||
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
|
||||
/// status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
let wait = readiness(fd, libc::POLLOUT)?;
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Wait for output from a nonblocking PTY without delaying the child's next read.
|
||||
#[cfg(unix)]
|
||||
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
readiness(fd, libc::POLLIN)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
// Own a duplicate so the hook remains valid if the caller closes its fd.
|
||||
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicate == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fcntl returned a new, owned descriptor.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
|
||||
Ok(move || {
|
||||
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
|
||||
// Readiness wakes immediately; the timeout only bounds abort latency.
|
||||
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if pollfd.revents & libc::POLLNVAL != 0 {
|
||||
return Err(io::Error::from_raw_os_error(libc::EBADF));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -221,6 +264,11 @@ mod tests {
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -243,6 +291,11 @@ mod tests {
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -256,6 +309,26 @@ mod tests {
|
||||
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn readiness_errors_are_reported_and_reject_following_writes() {
|
||||
let (dropped, _rx) = mpsc::channel();
|
||||
let (errors, error) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-error-input".into(),
|
||||
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|
||||
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
|
||||
move |e| errors.send(e.raw_os_error()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write(b"input").unwrap();
|
||||
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
|
||||
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
|
||||
let state = writer.shared.0.lock().unwrap();
|
||||
assert!(state.queue.is_empty());
|
||||
assert_eq!(state.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_writes_are_also_bounded() {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
@@ -266,14 +339,59 @@ mod tests {
|
||||
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_input_resumes_without_timer_delays() {
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let (pipe, mut peer) = UnixStream::pair().unwrap();
|
||||
let capacity: libc::c_int = 1024;
|
||||
// Keep the OS buffer small enough that a timer per buffer takes seconds.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::setsockopt(
|
||||
pipe.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_SNDBUF,
|
||||
(&capacity as *const libc::c_int).cast(),
|
||||
std::mem::size_of_val(&capacity) as libc::socklen_t,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let writer =
|
||||
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
writer.finish();
|
||||
// Start with a full buffer, then make the fd writable from another thread.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let started = Instant::now();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
peer.read_to_end(&mut output).unwrap();
|
||||
tx.send(output).unwrap();
|
||||
});
|
||||
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
|
||||
assert_eq!(output, bytes);
|
||||
reader.join().unwrap();
|
||||
eprintln!("4 MiB input drained in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (pipe, _peer) = UnixStream::pair().unwrap();
|
||||
nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer = InputWriter::new("test-pipe-input".into(), Box::new(pipe), |e| panic!("{e}")).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer =
|
||||
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(!writer.worker.as_ref().unwrap().is_finished());
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod platform;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod project_roots;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
|
||||
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
|
||||
|
||||
let mut info =
|
||||
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
|
||||
// SAFETY: `info` is initialized and declares the size of the writable structure.
|
||||
let status = unsafe { RtlGetVersion(&mut info) };
|
||||
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn windows_build_is_platform_specific() {
|
||||
let build = super::windows_build_number();
|
||||
#[cfg(windows)]
|
||||
assert!(build.is_some_and(|build| build > 0));
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(build, None);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,12 @@ const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree_of(pid: u32) {
|
||||
let started = windows_tree::descendants(pid);
|
||||
// When the child started nothing, killing it alone is the whole job:
|
||||
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
|
||||
// same outcome, and the callers kill the child themselves.
|
||||
if started.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
|
||||
@@ -191,6 +191,19 @@ pub struct RunTarget {
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
/// A plugin-owned argv wrapper for project runs (API 1.4).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRunTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub workdir_args: Vec<String>,
|
||||
pub container_root: Option<String>,
|
||||
pub commands: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -629,6 +629,8 @@ impl Default for FileSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguagesSettings {
|
||||
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
|
||||
pub plugin_formatters: BTreeMap<String, String>,
|
||||
pub python: PythonSettings,
|
||||
pub node: NodeSettings,
|
||||
pub php: PhpSettings,
|
||||
@@ -646,6 +648,7 @@ pub struct LanguagesSettings {
|
||||
impl Default for LanguagesSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_formatters: BTreeMap::new(),
|
||||
python: PythonSettings::default(),
|
||||
node: NodeSettings::default(),
|
||||
php: PhpSettings::default(),
|
||||
@@ -767,6 +770,8 @@ impl Default for PythonSettings {
|
||||
pub struct PhpSettings {
|
||||
/// `php` CLI executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// PHP version the language server checks the code against (`7.4`); empty = from
|
||||
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
pub language_level: String,
|
||||
@@ -794,6 +799,8 @@ pub struct RubySettings {
|
||||
pub struct NodeSettings {
|
||||
/// `node` executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
|
||||
pub package_manager: String,
|
||||
}
|
||||
|
||||
@@ -272,6 +272,8 @@ impl Default for PythonDebuggerSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunSettings {
|
||||
/// Target for configurations without an explicit target. Empty = local.
|
||||
pub default_target: String,
|
||||
pub confirm_rerun: bool,
|
||||
/// Ask before closing a Run tab whose process is still running.
|
||||
pub confirm_terminate: bool,
|
||||
@@ -282,7 +284,13 @@ pub struct RunSettings {
|
||||
|
||||
impl Default for RunSettings {
|
||||
fn default() -> Self {
|
||||
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
|
||||
Self {
|
||||
default_target: String::new(),
|
||||
confirm_rerun: true,
|
||||
confirm_terminate: true,
|
||||
show_on_start: true,
|
||||
termination_timeout_ms: 3000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -630,9 +630,15 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
if let Some(fd) = pair.master.as_raw_fd() {
|
||||
ide_core::input::nonblocking(fd).map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
}
|
||||
let wait_writable = ide_core::input::nonblocking(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_readable = ide_core::input::readable(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
@@ -662,16 +668,27 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = ide_core::input::InputWriter::new(format!("openidea-debug-input-{}", s.id.0), writer, |e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
})
|
||||
let writer = ide_core::input::InputWriter::new(
|
||||
format!("openidea-debug-input-{}", s.id.0),
|
||||
writer,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
|e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
},
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|e| format!("terminal input worker: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(s, reader);
|
||||
forward_terminal_output(
|
||||
s,
|
||||
reader,
|
||||
#[cfg(unix)]
|
||||
wait_readable,
|
||||
);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
@@ -680,7 +697,11 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
fn forward_terminal_output(
|
||||
s: &Session,
|
||||
mut reader: Box<dyn std::io::Read + Send>,
|
||||
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
|
||||
) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
@@ -692,6 +713,14 @@ fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = wait_readable()
|
||||
&& e.kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
blake3.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
+25
-13
@@ -7,6 +7,7 @@ pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
@@ -381,7 +382,9 @@ impl DbManager {
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
|
||||
store
|
||||
}
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
@@ -412,19 +415,28 @@ impl DbManager {
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
|
||||
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
let old = {
|
||||
let _guard = plugin::EDIT.lock();
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => {
|
||||
return Err(IdeError::invalid("global data sources are not available here"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
old
|
||||
};
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Ownership checks and surgical updates of plugin-created project sources.
|
||||
use crate::{DataSource, DbManager, source};
|
||||
use ide_core::{IdeError, IdeResult, project_dir};
|
||||
use std::path::Path;
|
||||
|
||||
// All DbManagers of the same root share this file. Never hold this across an await.
|
||||
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||
|
||||
/// Host-owned connection id, unambiguous and safe for console paths.
|
||||
pub fn source_id(plugin: &str, id: &str) -> String {
|
||||
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
|
||||
}
|
||||
|
||||
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
|
||||
source.id == source_id(plugin, id)
|
||||
&& source.plugin_owner.as_deref() == Some(plugin)
|
||||
&& source.plugin_source_id.as_deref() == Some(id)
|
||||
&& !source.global
|
||||
}
|
||||
|
||||
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
|
||||
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
|
||||
let path = project_dir::path(root, "data-sources.toml");
|
||||
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
|
||||
&& root.exists()
|
||||
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
|
||||
{
|
||||
return Err(IdeError::invalid("data sources file is outside the project"));
|
||||
}
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let old = source::load(root)?;
|
||||
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
|
||||
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
|
||||
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
|
||||
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
|
||||
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
|
||||
while end < text.len() {
|
||||
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
|
||||
if !text[end..next].trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
out.push(start..end);
|
||||
}
|
||||
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
|
||||
if !table.is_implicit()
|
||||
&& let Some(span) = table.span()
|
||||
{
|
||||
line_span(text, span, out);
|
||||
}
|
||||
for (_, item) in table.iter() {
|
||||
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
|
||||
line_span(text, span, out);
|
||||
}
|
||||
if let Some(table) = item.as_table() {
|
||||
spans(table, text, out);
|
||||
}
|
||||
if let Some(tables) = item.as_array_of_tables() {
|
||||
for table in tables.iter() {
|
||||
spans(table, text, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
|
||||
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
|
||||
}
|
||||
let mut ranges = vec![];
|
||||
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
|
||||
for (table, source) in tables.iter().zip(&old) {
|
||||
if remove(source) {
|
||||
spans(table, &text, &mut ranges);
|
||||
}
|
||||
}
|
||||
}
|
||||
ranges.sort_by_key(|r| r.start);
|
||||
let mut merged: Vec<std::ops::Range<usize>> = vec![];
|
||||
for range in ranges {
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& range.start <= last.end
|
||||
{
|
||||
last.end = last.end.max(range.end);
|
||||
continue;
|
||||
}
|
||||
merged.push(range);
|
||||
}
|
||||
let mut output = text.clone();
|
||||
for range in merged.into_iter().rev() {
|
||||
output.replace_range(range, "");
|
||||
}
|
||||
if let Some(add) = add {
|
||||
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
value.as_table_mut().unwrap().remove("global");
|
||||
let value =
|
||||
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
|
||||
if !output.is_empty() && !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
|
||||
}
|
||||
if !removed.is_empty() || add.is_some() {
|
||||
let dir = path.parent().unwrap();
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&tmp, output)?;
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Removes only this plugin's sources, including when their project is closed at uninstall.
|
||||
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
|
||||
let _guard = EDIT.lock();
|
||||
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
|
||||
}
|
||||
|
||||
impl DbManager {
|
||||
/// Lists private source ids without exposing any connection settings.
|
||||
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
|
||||
Ok(source::load(&self.root)?
|
||||
.into_iter()
|
||||
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
|
||||
.filter_map(|s| s.plugin_source_id)
|
||||
.collect())
|
||||
}
|
||||
/// Checks the stored owner, even if a user-created source uses the host's id format.
|
||||
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
|
||||
source::load(&self.root)?
|
||||
.into_iter()
|
||||
.find(|s| owned(s, plugin, id))
|
||||
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
|
||||
}
|
||||
/// Replaces only an owned source and disconnects its old pools.
|
||||
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
|
||||
source.id = source_id(plugin, id);
|
||||
source.plugin_owner = Some(plugin.into());
|
||||
source.plugin_source_id = Some(id.into());
|
||||
source.global = false;
|
||||
{
|
||||
let _guard = EDIT.lock();
|
||||
let all = self.sources()?;
|
||||
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
|
||||
return Err(IdeError::not_found("no owned data source with this id"));
|
||||
}
|
||||
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
|
||||
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
|
||||
}
|
||||
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
|
||||
}
|
||||
self.disconnect_source(&source.id).await;
|
||||
Ok(())
|
||||
}
|
||||
/// Deletes an owned source and all its saved/session credentials.
|
||||
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
|
||||
let source = {
|
||||
let _guard = EDIT.lock();
|
||||
let source = self.plugin_source(plugin, id)?;
|
||||
update(&self.root, |s| owned(s, plugin, id), None)?;
|
||||
source
|
||||
};
|
||||
self.forget_plugin_source(&source.id).await;
|
||||
Ok(())
|
||||
}
|
||||
/// Completes cleanup after removal from the file.
|
||||
pub async fn forget_plugin_source(&self, id: &str) {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
}
|
||||
self.disconnect_source(id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{DbDriver, secrets::SecretStore};
|
||||
#[tokio::test]
|
||||
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
|
||||
let file = root.join(".openidea/data-sources.toml");
|
||||
std::fs::write(&file, original).unwrap();
|
||||
let secrets = tmp.path().join("passwords.json");
|
||||
let db = DbManager::new(
|
||||
root.clone(),
|
||||
secrets.clone(),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
);
|
||||
let settings =
|
||||
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
|
||||
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
|
||||
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
|
||||
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
|
||||
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
|
||||
assert!(db.plugin_source("a", "user").is_err());
|
||||
assert!(db.remove_plugin_source("a", "user").await.is_err());
|
||||
let source = db.plugin_source("a", "local").unwrap();
|
||||
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
|
||||
let store = SecretStore::new(secrets, &root);
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
|
||||
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
|
||||
db.remove_plugin_source("a", "local").await.unwrap();
|
||||
assert!(store.get(&source.id).is_none());
|
||||
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
|
||||
remove_all(&root, "b").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let global = tmp.path().join("global");
|
||||
let passwords = tmp.path().join("passwords.json");
|
||||
let db = DbManager::new(
|
||||
root.clone(),
|
||||
passwords.clone(),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
)
|
||||
.with_global_sources(global.clone());
|
||||
db.upsert_plugin_source(
|
||||
"plugin",
|
||||
"local",
|
||||
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let owned = db.plugin_source("plugin", "local").unwrap();
|
||||
source::save_global(
|
||||
&global,
|
||||
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
|
||||
)
|
||||
.unwrap();
|
||||
let global_store = SecretStore::new(passwords.clone(), &global);
|
||||
global_store.set(&owned.id, "user-secret").unwrap();
|
||||
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
|
||||
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
|
||||
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
|
||||
db.remove_plugin_source("plugin", "local").await.unwrap();
|
||||
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db = DbManager::new(
|
||||
tmp.path().join("project"),
|
||||
tmp.path().join("passwords.json"),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
);
|
||||
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
|
||||
db.save_sources(vec![source.clone()]).await.unwrap();
|
||||
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
|
||||
assert_eq!(db.sources().unwrap(), [source]);
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,12 @@ pub struct DataSource {
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
/// Plugin id owning this source; absent for user-managed sources.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_owner: Option<String>,
|
||||
/// Id private to the owner, never used as a connection target.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_source_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -219,6 +225,8 @@ impl Default for DataSource {
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
plugin_owner: None,
|
||||
plugin_source_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ pub enum Capability {
|
||||
EditorWrite,
|
||||
/// Create and replace files inside the project root.
|
||||
ProjectWrite,
|
||||
/// Run arbitrary IDE actions and other plugins' commands.
|
||||
IdeCommands,
|
||||
/// Manage only its own project database sources and write their credentials.
|
||||
DatabaseSources,
|
||||
VcsRead,
|
||||
VcsWrite,
|
||||
/// The plugin's own key-value store and data directory.
|
||||
@@ -32,8 +36,12 @@ pub enum Capability {
|
||||
InputEvents,
|
||||
/// The system clipboard, from the plugin's web views.
|
||||
Clipboard,
|
||||
/// HTTP(S) to one host (`*.example.com` matches subdomains).
|
||||
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Unencrypted HTTP to one host.
|
||||
NetHttp(String),
|
||||
/// A local socket or named pipe on the project machine.
|
||||
Ipc(String),
|
||||
/// Run one external program. Full code execution: never fine-grained.
|
||||
Process(String),
|
||||
/// Read files outside the project under one path.
|
||||
@@ -60,12 +68,14 @@ impl Capability {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite
|
||||
| Self::ProjectWrite
|
||||
| Self::DatabaseSources
|
||||
| Self::VcsWrite
|
||||
| Self::Net(_)
|
||||
| Self::NetHttp(_)
|
||||
| Self::Secrets
|
||||
| Self::InputEvents
|
||||
| Self::Clipboard => Risk::Medium,
|
||||
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +92,8 @@ impl Capability {
|
||||
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::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
|
||||
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".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(),
|
||||
@@ -92,6 +104,12 @@ impl Capability {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::NetHttp(host) => {
|
||||
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
|
||||
}
|
||||
Self::Ipc(path) => {
|
||||
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
|
||||
}
|
||||
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"),
|
||||
@@ -100,8 +118,14 @@ impl Capability {
|
||||
|
||||
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
|
||||
pub fn covers(&self, wanted: &Capability) -> bool {
|
||||
if self == wanted {
|
||||
return true;
|
||||
}
|
||||
match (self, wanted) {
|
||||
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
|
||||
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
|
||||
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
|
||||
host_matches(pattern, host)
|
||||
}
|
||||
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
|
||||
std::path::Path::new(path).starts_with(std::path::Path::new(base))
|
||||
}
|
||||
@@ -120,6 +144,75 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a socket path; a grant may use `*` in its last segment only.
|
||||
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
|
||||
let pipe = name.starts_with(r"\\.\pipe\");
|
||||
let unix = name.starts_with('/')
|
||||
|| name.starts_with("~/")
|
||||
|| name.starts_with("$TMPDIR/")
|
||||
|| name.starts_with("$XDG_RUNTIME_DIR/");
|
||||
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
|
||||
if !(pipe || unix)
|
||||
|| name.contains(['\0', '?'])
|
||||
|| name.len() > 4096
|
||||
|| parts.last().is_none_or(|p| p.is_empty())
|
||||
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|
||||
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|
||||
|| (!pattern && name.contains('*'))
|
||||
{
|
||||
return Err(
|
||||
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expands the three supported host-local prefixes; no shell is involved.
|
||||
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
|
||||
validate_ipc_name(name, true)?;
|
||||
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
|
||||
if let Some(rest) = name.strip_prefix(prefix) {
|
||||
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
|
||||
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
|
||||
validate_ipc_name(&expanded, true)?;
|
||||
return Ok(expanded);
|
||||
}
|
||||
}
|
||||
Ok(name.into())
|
||||
}
|
||||
|
||||
fn ipc_matches(pattern: &str, name: &str) -> bool {
|
||||
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
|
||||
if validate_ipc_name(&name, false).is_err() {
|
||||
return false;
|
||||
}
|
||||
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
|
||||
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if parent != other {
|
||||
return false;
|
||||
}
|
||||
let mut rest = name;
|
||||
let mut pieces = pattern.split('*');
|
||||
let first = pieces.next().unwrap_or_default();
|
||||
let Some(tail) = rest.strip_prefix(first) else { return false };
|
||||
rest = tail;
|
||||
let pieces: Vec<_> = pieces.collect();
|
||||
if pieces.is_empty() {
|
||||
return rest.is_empty();
|
||||
}
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
if i + 1 == pieces.len() {
|
||||
return rest.ends_with(piece);
|
||||
}
|
||||
let Some(at) = rest.find(piece) else { return false };
|
||||
rest = &rest[at + piece.len()..];
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl fmt::Display for Capability {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -127,6 +220,8 @@ impl fmt::Display for Capability {
|
||||
Self::EditorRead => f.write_str("editor.read"),
|
||||
Self::EditorWrite => f.write_str("editor.write"),
|
||||
Self::ProjectWrite => f.write_str("project.write"),
|
||||
Self::IdeCommands => f.write_str("ide.commands"),
|
||||
Self::DatabaseSources => f.write_str("database.sources"),
|
||||
Self::VcsRead => f.write_str("vcs.read"),
|
||||
Self::VcsWrite => f.write_str("vcs.write"),
|
||||
Self::Storage => f.write_str("storage"),
|
||||
@@ -135,6 +230,8 @@ impl fmt::Display for Capability {
|
||||
Self::InputEvents => f.write_str("events.input"),
|
||||
Self::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::NetHttp(h) => write!(f, "net.http:{h}"),
|
||||
Self::Ipc(p) => write!(f, "ipc:{p}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
Self::FsRead(p) => write!(f, "fs.read:{p}"),
|
||||
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
|
||||
@@ -153,7 +250,7 @@ impl FromStr for Capability {
|
||||
return Err(format!("capability {s:?} needs a value after ':'"));
|
||||
}
|
||||
return match kind {
|
||||
"net" => {
|
||||
"net" | "net.http" => {
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
@@ -162,7 +259,19 @@ impl FromStr for Capability {
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
});
|
||||
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
|
||||
if valid {
|
||||
Ok(if kind == "net" {
|
||||
Self::Net(arg.to_ascii_lowercase())
|
||||
} else {
|
||||
Self::NetHttp(arg.to_ascii_lowercase())
|
||||
})
|
||||
} else {
|
||||
Err(format!("invalid host in {s:?}"))
|
||||
}
|
||||
}
|
||||
"ipc" => {
|
||||
validate_ipc_name(arg, true)?;
|
||||
Ok(Self::Ipc(arg.to_owned()))
|
||||
}
|
||||
"process" => {
|
||||
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
|
||||
@@ -184,6 +293,8 @@ impl FromStr for Capability {
|
||||
"editor.read" | "editor-read" => Self::EditorRead,
|
||||
"editor.write" | "editor-write" => Self::EditorWrite,
|
||||
"project.write" => Self::ProjectWrite,
|
||||
"ide.commands" => Self::IdeCommands,
|
||||
"database.sources" => Self::DatabaseSources,
|
||||
"vcs.read" => Self::VcsRead,
|
||||
"vcs.write" => Self::VcsWrite,
|
||||
"storage" => Self::Storage,
|
||||
@@ -292,3 +403,71 @@ mod tests {
|
||||
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod api14_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
|
||||
let http: Capability = "net.http:*.example.com".parse().unwrap();
|
||||
assert_eq!(http.to_string(), "net.http:*.example.com");
|
||||
assert_eq!(http.risk(), Risk::Medium);
|
||||
assert!(http.describe().contains("unencrypted"));
|
||||
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
|
||||
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
|
||||
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
|
||||
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
|
||||
assert_eq!(socket.risk(), Risk::High);
|
||||
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
|
||||
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
|
||||
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
|
||||
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
|
||||
assert!(
|
||||
r"ipc:\\.\pipe\discord-*"
|
||||
.parse::<Capability>()
|
||||
.unwrap()
|
||||
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
|
||||
);
|
||||
for invalid in
|
||||
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
|
||||
{
|
||||
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ide_commands_is_explicit_and_high_risk() {
|
||||
let cap: Capability = "ide.commands".parse().unwrap();
|
||||
assert_eq!(cap, Capability::IdeCommands);
|
||||
assert_eq!(cap.to_string(), "ide.commands");
|
||||
assert_eq!(cap.risk(), Risk::High);
|
||||
assert!(!cap.auto_migrates());
|
||||
assert!(!Capability::VcsWrite.covers(&cap));
|
||||
assert!(!Capability::Process("git".into()).covers(&cap));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod database_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
|
||||
let cap: Capability = "database.sources".parse().unwrap();
|
||||
assert_eq!(cap, Capability::DatabaseSources);
|
||||
assert_eq!(cap.to_string(), "database.sources");
|
||||
assert_eq!(cap.risk(), Risk::Medium);
|
||||
assert!(!cap.auto_migrates());
|
||||
assert!(!Capability::ProjectRead.covers(&cap));
|
||||
assert!(!Capability::ProjectWrite.covers(&cap));
|
||||
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
|
||||
let text = format!(
|
||||
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
|
||||
);
|
||||
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,12 @@ pub struct Grant {
|
||||
/// `process:<program>` → the program confirmed for it.
|
||||
#[serde(default)]
|
||||
pub binaries: BTreeMap<String, BinaryGrant>,
|
||||
/// Project root → process grant → the executable approved for that project.
|
||||
#[serde(default)]
|
||||
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
|
||||
/// Explicit global executable choices, retained for the next re-approval.
|
||||
#[serde(default)]
|
||||
pub program_choices: BTreeMap<String, PathBuf>,
|
||||
/// Migrated from 0.1: capabilities not granted yet are asked for on first
|
||||
/// use instead of blocking the plugin from loading.
|
||||
#[serde(default)]
|
||||
@@ -248,6 +254,9 @@ impl Ledger {
|
||||
} else {
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
for binaries in grant.project_binaries.values_mut() {
|
||||
binaries.remove(&s);
|
||||
}
|
||||
}
|
||||
grant.granted.remove(&s)
|
||||
}
|
||||
@@ -260,6 +269,58 @@ impl Ledger {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's explicit global executable choice, if any.
|
||||
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
|
||||
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
|
||||
}
|
||||
/// Remembers or resets an explicit global executable choice after confirmation.
|
||||
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
if let Some(path) = path {
|
||||
grant.program_choices.insert(cap.to_string(), path.into());
|
||||
} else {
|
||||
grant.program_choices.remove(&cap.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a project-specific program choice after user confirmation.
|
||||
pub fn set_project_binary(
|
||||
&mut self,
|
||||
subject: &Subject,
|
||||
cap: &Capability,
|
||||
root: &Path,
|
||||
binary: Option<BinaryGrant>,
|
||||
) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
if let Some(binary) = binary {
|
||||
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
|
||||
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
|
||||
binaries.remove(&cap.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The project override when present, otherwise the global approved program.
|
||||
pub fn check_binary_in_project(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
cap: &Capability,
|
||||
root: Option<&Path>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
|
||||
if !grant.has(cap) {
|
||||
return Err(format!("{cap} was not granted"));
|
||||
}
|
||||
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
|
||||
{
|
||||
return match file_hash(&bin.path) {
|
||||
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
|
||||
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
|
||||
};
|
||||
}
|
||||
self.check_binary(subject, cap)
|
||||
}
|
||||
|
||||
/// Checks a `process:` grant at call time: the program confirmed for it,
|
||||
/// unchanged since. Errors name what to do.
|
||||
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
|
||||
@@ -306,6 +367,16 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
|
||||
}
|
||||
|
||||
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
|
||||
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
|
||||
if !path.is_absolute() || !is_executable(path) {
|
||||
return Err("choose an absolute executable file".into());
|
||||
}
|
||||
let path = canonical_dir(path);
|
||||
let hash = file_hash(&path).map_err(|e| e.to_string())?;
|
||||
Ok(BinaryGrant { path, hash })
|
||||
}
|
||||
|
||||
/// 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`,
|
||||
|
||||
@@ -24,7 +24,7 @@ mod registry;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
|
||||
pub use index::ContributionIndex;
|
||||
pub use manifest::{
|
||||
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.3.0";
|
||||
pub const ENGINE_VERSION: &str = "1.4.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
@@ -144,8 +144,20 @@ impl ActivationEvent {
|
||||
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
pub const EVENT_KINDS: &[&str] =
|
||||
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
|
||||
pub const EVENT_KINDS: &[&str] = &[
|
||||
"run-exited",
|
||||
"terminal-exited",
|
||||
"content-tab-action",
|
||||
"file-closed",
|
||||
"settings-changed",
|
||||
"input-mode-changed",
|
||||
"file-opened",
|
||||
"file-saved",
|
||||
"files-changed",
|
||||
"editor-focused",
|
||||
"action-performed",
|
||||
"vcs-changed",
|
||||
];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -317,6 +329,12 @@ impl Manifest {
|
||||
check_relative(icon, "icon")?;
|
||||
}
|
||||
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
|
||||
if matches!(
|
||||
cap,
|
||||
Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
|
||||
) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 4)?;
|
||||
}
|
||||
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 3)?;
|
||||
}
|
||||
@@ -332,6 +350,10 @@ impl Manifest {
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
|
||||
self.needs_api("`on-event:files-changed`", 3)?;
|
||||
}
|
||||
@@ -655,3 +677,44 @@ mod tests {
|
||||
assert!(!engine_compatible("0.10", true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod api14_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn new_capabilities_and_activations_require_api_1_4() {
|
||||
for declaration in [
|
||||
"[capabilities]\nrequired = [\"net.http:example.com\"]",
|
||||
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
|
||||
"activation = [\"on-event:file-closed\"]",
|
||||
"activation = [\"on-event:settings-changed\"]",
|
||||
"activation = [\"on-event:input-mode-changed\"]",
|
||||
"activation = [\"on-event:run-exited\"]",
|
||||
"activation = [\"on-event:terminal-exited\"]",
|
||||
"activation = [\"on-event:content-tab-action\"]",
|
||||
] {
|
||||
for engine in ["^1.0", "^1.3", "^1.4"] {
|
||||
let manifest = format!(
|
||||
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
|
||||
);
|
||||
let parsed = Manifest::parse(&manifest);
|
||||
if engine == "^1.4" {
|
||||
parsed.unwrap();
|
||||
} else {
|
||||
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands_api_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ide_commands_requires_api_1_4() {
|
||||
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
|
||||
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
|
||||
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
jsonschema = { version = "0.33", default-features = false }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -130,6 +130,38 @@ pub async fn format(
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let language = project.languages.get(&snap.language);
|
||||
if options.tool.is_none()
|
||||
&& let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty())
|
||||
{
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let plugins = crate::plugins::editor_plugin_host()
|
||||
.ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?;
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
let id = id.clone();
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
plugins.language_format(
|
||||
&id,
|
||||
&d,
|
||||
range,
|
||||
ide_plugin::language_features::FormatOptions {
|
||||
tab_size: options.tab_size,
|
||||
insert_spaces: options.insert_spaces,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
|
||||
if options.tool.is_none()
|
||||
&& snap.path.extension().is_some_and(|e| e == "php")
|
||||
|
||||
@@ -55,6 +55,8 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
pub trust: trust::TrustStore,
|
||||
@@ -99,6 +101,14 @@ pub struct Host {
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
/// Windows whose current project has this root.
|
||||
pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec<String> {
|
||||
let mut windows: Vec<_> =
|
||||
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
|
||||
windows.sort();
|
||||
windows
|
||||
}
|
||||
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
config_dir,
|
||||
@@ -115,6 +125,8 @@ impl Host {
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
plugin_host: RwLock::default(),
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
@@ -166,6 +178,19 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Associates the plugin runtime that executes this host's project operations.
|
||||
/// Publishes only the executable of the running built-in MCP service.
|
||||
pub fn set_mcp_executable(&self, executable: Option<PathBuf>) {
|
||||
*self.mcp_executable.write() = executable;
|
||||
}
|
||||
|
||||
pub fn set_plugin_host(&self, plugins: &Arc<plugins::PluginHost>) {
|
||||
*self.plugin_host.write() = Arc::downgrade(plugins);
|
||||
}
|
||||
pub(crate) fn plugin_host(&self) -> Option<Arc<plugins::PluginHost>> {
|
||||
self.plugin_host.read().upgrade()
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
@@ -231,6 +256,11 @@ impl Host {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if self.windows_for_root(&project.root).is_empty()
|
||||
&& let Some(plugins) = self.plugin_host()
|
||||
{
|
||||
plugins.withdraw_project_run_targets(&project.root);
|
||||
}
|
||||
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
|
||||
project.plugin_diagnostics.close();
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
|
||||
@@ -75,7 +75,11 @@ impl ToolRegistry for Registry {
|
||||
}
|
||||
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
|
||||
let (_, project) = self.target(root)?;
|
||||
Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect())
|
||||
let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect();
|
||||
if let Some(plugins) = self.0.plugin_host() {
|
||||
all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool));
|
||||
}
|
||||
Ok(all.into_iter().filter(|t| allowed(t, project.is_trusted())).collect())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
@@ -95,6 +99,12 @@ impl ToolRegistry for Registry {
|
||||
client: &str,
|
||||
) -> Result<String, ToolCallError> {
|
||||
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
|
||||
if let Some(plugins) = self.0.plugin_host()
|
||||
&& let Some((plugin, tool, metadata)) =
|
||||
plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted()))
|
||||
{
|
||||
return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client);
|
||||
}
|
||||
let tool = tools()
|
||||
.into_iter()
|
||||
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
|
||||
@@ -129,6 +139,51 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn call_plugin(
|
||||
&self,
|
||||
window: String,
|
||||
project: Arc<crate::Project>,
|
||||
plugins: Arc<crate::plugins::PluginHost>,
|
||||
plugin: String,
|
||||
tool: String,
|
||||
metadata: ide_mcp::Tool,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
client: &str,
|
||||
) -> Result<String, ToolCallError> {
|
||||
let run = || -> ide_core::IdeResult<String> {
|
||||
let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let call = tool::Call {
|
||||
host: self.0.clone(),
|
||||
project: project.clone(),
|
||||
window: window.clone(),
|
||||
args,
|
||||
cancel: cancel.clone(),
|
||||
client: client.into(),
|
||||
tool: metadata.name,
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if metadata.destructive {
|
||||
self.0.runtime.block_on(call.confirm(&metadata.description, None))?;
|
||||
}
|
||||
if !metadata.read_only {
|
||||
project.require_trust()?;
|
||||
}
|
||||
let invocation = ide_plugin::Invocation {
|
||||
client: Some(window),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel)
|
||||
};
|
||||
run().map_err(|e| ToolCallError::Failed(e.message))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -91,7 +91,7 @@ impl Reports {
|
||||
pub(super) fn focused_window(&self) -> Option<String> {
|
||||
self.0.read().focused.clone()
|
||||
}
|
||||
pub(super) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
pub(crate) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
let state = self.0.read();
|
||||
let mut paths = Vec::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
@@ -103,10 +103,10 @@ impl Reports {
|
||||
}
|
||||
paths
|
||||
}
|
||||
pub(super) fn focused(&self, window: &str) -> Option<FocusedEditor> {
|
||||
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
|
||||
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
|
||||
}
|
||||
pub(super) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
|
||||
pub(crate) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
|
||||
let state = self.0.read();
|
||||
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
|
||||
@@ -169,7 +169,12 @@ pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
|
||||
/// Replaces the project's data sources with `sources`.
|
||||
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.save_sources(sources).await
|
||||
db.save_sources(sources).await?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
project.sink.emit(ide_core::IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remembers the password of data source `id` for the session, and in the OS
|
||||
|
||||
@@ -52,6 +52,20 @@ pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: L
|
||||
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
let lsp = if lsp.as_ref().is_none_or(|v| v.is_empty()) {
|
||||
if let Some((plugins, d)) =
|
||||
crate::ops::lsp::plugin_providers(host, window, doc, crate::plugins::ProviderKind::DocumentSymbols)
|
||||
{
|
||||
let items =
|
||||
crate::ops::lsp::plugin_call(move || plugins.language_document_symbols(&d)).await.unwrap_or_default();
|
||||
if items.is_empty() { lsp } else { Some(crate::plugins::language_features::outline(items)) }
|
||||
} else {
|
||||
lsp
|
||||
}
|
||||
} else {
|
||||
lsp
|
||||
};
|
||||
let lsp = lsp.filter(|v| !v.is_empty());
|
||||
blocking(move || {
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
@@ -80,7 +94,27 @@ pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
|
||||
let mut folds = project.syntax.folding(doc, &snap.language, snap.version, &snap.text);
|
||||
if let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::Folding) {
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: project.documents.identity(doc)?.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
folds.extend(plugins.language_folding(&d).into_iter().map(|f| FoldRange {
|
||||
start_line: f.start_line,
|
||||
end_line: f.end_line,
|
||||
kind: match f.kind.as_str() {
|
||||
"comment" => ide_language::FoldKind::Comment,
|
||||
"imports" => ide_language::FoldKind::Imports,
|
||||
_ => ide_language::FoldKind::Region,
|
||||
},
|
||||
}));
|
||||
folds.sort_by_key(|f| (f.start_line, f.end_line));
|
||||
folds.dedup_by_key(|f| (f.start_line, f.end_line));
|
||||
}
|
||||
Ok(folds)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -105,6 +139,21 @@ pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset:
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if !snap.large
|
||||
&& let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::SelectionRanges)
|
||||
{
|
||||
let at = ide_text::Document::new(&snap.text).offset_to_pos(offset);
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: project.documents.identity(doc)?.0,
|
||||
language: snap.language.0.clone(),
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text.clone(),
|
||||
};
|
||||
let ranges = plugins.language_selection_ranges(&d, at);
|
||||
if !ranges.is_empty() {
|
||||
return Ok(ranges);
|
||||
}
|
||||
}
|
||||
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
|
||||
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
|
||||
})
|
||||
|
||||
+268
-14
@@ -56,7 +56,7 @@ pub async fn lsp_completion(
|
||||
}
|
||||
|
||||
/// The plugin host and the document, when some plugin provides `kind`.
|
||||
fn plugin_providers(
|
||||
pub(crate) fn plugin_providers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
@@ -74,10 +74,48 @@ fn plugin_providers(
|
||||
}
|
||||
|
||||
/// Plugin calls block (the plugin's actor thread does the work).
|
||||
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
pub(crate) async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
fn provider_server<T: Default>(server: IdeResult<T>) -> IdeResult<T> {
|
||||
match server {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Ok(T::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_language_locations(mut server: Vec<Location>, plugin: Vec<ide_plugin::Location>) -> Vec<Location> {
|
||||
let mut seen: std::collections::HashSet<_> = server.iter().cloned().collect();
|
||||
for l in plugin {
|
||||
let loc = Location::file(l.path, l.range);
|
||||
if seen.insert(loc.clone()) {
|
||||
server.push(loc);
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
fn merge_language_highlights(
|
||||
mut server: Vec<Highlight>,
|
||||
plugin: Vec<ide_plugin::language_features::Highlight>,
|
||||
) -> Vec<Highlight> {
|
||||
let mut seen: std::collections::HashSet<_> = server.iter().map(|h| h.range).collect();
|
||||
for h in plugin {
|
||||
if seen.insert(h.range) {
|
||||
server.push(Highlight {
|
||||
range: h.range,
|
||||
kind: match h.kind.as_str() {
|
||||
"read" => HighlightKind::Read,
|
||||
"write" => HighlightKind::Write,
|
||||
_ => HighlightKind::Text,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
|
||||
/// Items from plugins carry no server: commands don't apply, and a code
|
||||
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
|
||||
const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
@@ -222,7 +260,21 @@ pub async fn lsp_hover(
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
host.project(window)?.lsp.signature_help(doc, pos).await
|
||||
let server = host.project(window)?.lsp.signature_help(doc, pos).await;
|
||||
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
|
||||
return server;
|
||||
}
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SignatureHelp) else {
|
||||
return server;
|
||||
};
|
||||
let help = plugin_call(move || plugins.language_signature_help(&d, pos)).await.flatten();
|
||||
match help {
|
||||
Some(h) => {
|
||||
let _ = provider_server(server)?;
|
||||
Ok(Some(crate::plugins::language_features::signature_help(h)))
|
||||
}
|
||||
None => server,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
@@ -238,6 +290,23 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
|
||||
}
|
||||
}
|
||||
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
|
||||
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
|
||||
let k =
|
||||
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, k) else {
|
||||
return result;
|
||||
};
|
||||
let locations = plugin_call(move || {
|
||||
if kind == GotoKind::Implementation {
|
||||
plugins.language_implementation(&d, pos)
|
||||
} else {
|
||||
plugins.language_type_definition(&d, pos)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
return Ok(merge_language_locations(provider_server(result)?, locations));
|
||||
}
|
||||
if kind != GotoKind::Definition {
|
||||
return result;
|
||||
}
|
||||
@@ -358,6 +427,20 @@ pub async fn lsp_implementations(
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.implementations(doc, &positions, cancel).await;
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let count = positions.len();
|
||||
let found = plugin_call(move || {
|
||||
positions.into_iter().map(|pos| plugins.language_implementation(&d, pos)).collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut out = provider_server(result)?;
|
||||
out.resize_with(count, Vec::new);
|
||||
for (items, found) in out.iter_mut().zip(found) {
|
||||
*items = merge_language_locations(std::mem::take(items), found);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -380,6 +463,10 @@ pub async fn lsp_goto_implementation(
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let found = plugin_call(move || plugins.language_implementation(&d, pos)).await.unwrap_or_default();
|
||||
return Ok(merge_language_locations(provider_server(result)?, found));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -505,10 +592,11 @@ fn validate_indexed_symbol(
|
||||
let stale = || {
|
||||
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
|
||||
};
|
||||
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
|
||||
let path = dunce::simplified(&file.path);
|
||||
if ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()).ok().as_deref() != Some(path) {
|
||||
return Err(stale());
|
||||
}
|
||||
let text = if let Some(doc) = project.documents.by_path(&file.path) {
|
||||
let text = if let Some(doc) = project.documents.by_path(path) {
|
||||
let snapshot = project.documents.text_snapshot(doc)?;
|
||||
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
|
||||
return Err(stale());
|
||||
@@ -517,7 +605,7 @@ fn validate_indexed_symbol(
|
||||
} else {
|
||||
let loaded = project
|
||||
.documents
|
||||
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.load_file_bounded(path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.map_err(|_| stale())?;
|
||||
if loaded.binary || loaded.large {
|
||||
return Err(stale());
|
||||
@@ -555,7 +643,12 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
|
||||
}
|
||||
|
||||
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
|
||||
host.project(window)?.lsp.document_highlights(doc, pos).await
|
||||
let server = host.project(window)?.lsp.document_highlights(doc, pos).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
|
||||
return server;
|
||||
};
|
||||
let items = plugin_call(move || plugins.language_document_highlights(&d, pos)).await.unwrap_or_default();
|
||||
Ok(merge_language_highlights(provider_server(server)?, items))
|
||||
}
|
||||
|
||||
pub async fn lsp_workspace_symbols(
|
||||
@@ -597,7 +690,7 @@ pub async fn lsp_code_actions(
|
||||
let actions = if plugin_actions.is_empty() {
|
||||
result?
|
||||
} else {
|
||||
let mut out = result.unwrap_or_default();
|
||||
let mut out = provider_server(result)?;
|
||||
out.extend(plugin_actions);
|
||||
out
|
||||
};
|
||||
@@ -1105,12 +1198,52 @@ pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeRe
|
||||
}
|
||||
|
||||
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
|
||||
host.project(window)?.lsp.code_lens(doc).await
|
||||
let server = host.project(window)?.lsp.code_lens(doc).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::CodeLenses) else {
|
||||
return server;
|
||||
};
|
||||
let items = plugin_call(move || plugins.language_code_lenses(&d)).await.unwrap_or_default();
|
||||
let mut out = provider_server(server)?;
|
||||
out.extend(items.into_iter().map(|(plugin, i)| CodeLens {
|
||||
range: i.range,
|
||||
command: Some(Command {
|
||||
title: i.title,
|
||||
command: format!("openidea.plugin-language:{plugin}:{}", i.command),
|
||||
arguments: vec![serde_json::json!(serde_json::to_string(&i.argument).unwrap_or_default())],
|
||||
}),
|
||||
}));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
|
||||
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
|
||||
host.project(window)?.lsp.document_links(doc).await
|
||||
let server = host.project(window)?.lsp.document_links(doc).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentLinks) else {
|
||||
return server;
|
||||
};
|
||||
let root = host.project(window)?.root.clone();
|
||||
let items = plugin_call(move || plugins.language_document_links(&d)).await.unwrap_or_default();
|
||||
let mut out = provider_server(server)?;
|
||||
out.extend(items.into_iter().filter_map(|i| {
|
||||
let url = i.target.starts_with("https://") || i.target.starts_with("http://");
|
||||
if !url && ide_core::is_virtual_uri(&i.target) {
|
||||
return None;
|
||||
}
|
||||
Some(DocumentLink {
|
||||
range: i.range,
|
||||
path: (!url).then(|| {
|
||||
{
|
||||
let path = std::path::PathBuf::from(&i.target);
|
||||
if path.is_absolute() { path } else { root.join(path) }
|
||||
}
|
||||
.into()
|
||||
}),
|
||||
url: url.then_some(i.target),
|
||||
fragment: None,
|
||||
tooltip: None,
|
||||
})
|
||||
}));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn lsp_call_hierarchy(
|
||||
@@ -1200,6 +1333,34 @@ pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResul
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
|
||||
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
if let Some(rest) = command.command.strip_prefix("openidea.plugin-language:") {
|
||||
let (plugin, command_id) =
|
||||
rest.split_once(':').ok_or_else(|| ide_core::IdeError::invalid("Invalid plugin command"))?;
|
||||
let plugins = crate::plugins::editor_plugin_host()
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("Plugin host unavailable"))?;
|
||||
let argument = command
|
||||
.arguments
|
||||
.first()
|
||||
.and_then(|a| a.as_str())
|
||||
.and_then(|s| serde_json::from_str::<Option<String>>(s).ok())
|
||||
.flatten();
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let invocation = ide_plugin::Invocation {
|
||||
client: Some(window.into()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: Some(ide_plugin::EditorContext {
|
||||
path: snap.path.0.clone(),
|
||||
language: snap.language.0,
|
||||
selections: vec![],
|
||||
}),
|
||||
selected_paths: vec![snap.path.0],
|
||||
..Default::default()
|
||||
};
|
||||
let (plugin, command_id) = (plugin.to_owned(), command_id.to_owned());
|
||||
return blocking(move || plugins.run_language_command(&plugin, &command_id, argument.as_deref(), invocation))
|
||||
.await;
|
||||
}
|
||||
host.project(window)?.lsp.execute_command(doc, command).await
|
||||
}
|
||||
|
||||
@@ -1262,7 +1423,74 @@ pub async fn lsp_execute_code_action(
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
/// Versioned plugin visuals, refreshed after the editor synchronizes its buffer.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLanguageVisuals {
|
||||
pub version: u64,
|
||||
pub injections: Vec<ide_plugin::language_features::Injection>,
|
||||
pub colors: Vec<(String, ide_plugin::language_features::ColorInfo)>,
|
||||
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
|
||||
}
|
||||
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
|
||||
let snap = host.project(window)?.documents.snapshot(doc)?;
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
let mut out =
|
||||
PluginLanguageVisuals { version: d.version, injections: vec![], colors: vec![], gutter_actions: vec![] };
|
||||
if d.text.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
out.injections = plugins.provide_injections(&d);
|
||||
out.colors = plugins.language_colors(&d);
|
||||
out.gutter_actions = plugins.language_gutter_actions(&d);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn plugin_color_presentations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
plugin: String,
|
||||
color: ide_plugin::language_features::ColorInfo,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let snap = host.project(window)?.documents.snapshot(doc)?;
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
if !crate::plugins::language_features::valid_range(&d, color.range) {
|
||||
return Err(ide_core::IdeError::invalid("Invalid color range"));
|
||||
}
|
||||
Ok(crate::plugins::editor_plugin_host()
|
||||
.map(|p| p.language_color_presentations(&plugin, &d, color))
|
||||
.unwrap_or_default())
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub fn plugin_formatters() -> Vec<crate::plugins::language_features::FormatterChoice> {
|
||||
crate::plugins::editor_plugin_host().map(|p| p.formatter_choices()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add(
|
||||
"plugin_language_visuals",
|
||||
|c| async move { plugin_language_visuals(&c.host, &c.window, c.arg("doc")?).await },
|
||||
);
|
||||
d.add("plugin_color_presentations", |c| async move {
|
||||
plugin_color_presentations(&c.host, &c.window, c.arg("doc")?, c.arg("plugin")?, c.arg("color")?).await
|
||||
});
|
||||
d.add("plugin_formatters", |_c| async move { Ok(plugin_formatters()) });
|
||||
d.add("lsp_completion", |c| async move {
|
||||
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
|
||||
});
|
||||
@@ -1408,6 +1636,32 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn language_features_merge_without_overwriting_lsp_ranges_or_masking_errors() {
|
||||
use super::*;
|
||||
let range = Range::new(Pos::default(), Pos::new(0, 1));
|
||||
let existing = Location::file("/p/a".into(), range);
|
||||
let merged = merge_language_locations(
|
||||
vec![existing.clone()],
|
||||
vec![
|
||||
ide_plugin::Location { path: "/p/a".into(), range },
|
||||
ide_plugin::Location { path: "/p/b".into(), range },
|
||||
],
|
||||
);
|
||||
assert_eq!(merged.len(), 2);
|
||||
assert_eq!(merged[0], existing);
|
||||
let merged = merge_language_highlights(
|
||||
vec![Highlight { range, kind: HighlightKind::Read }],
|
||||
vec![ide_plugin::language_features::Highlight { range, kind: "write".into() }],
|
||||
);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].kind, HighlightKind::Read);
|
||||
assert!(
|
||||
provider_server::<Vec<Location>>(Err(ide_core::IdeError::unavailable("no server"))).unwrap().is_empty()
|
||||
);
|
||||
assert!(provider_server::<Vec<Location>>(Err(ide_core::IdeError::cancelled())).is_err());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -1430,11 +1684,13 @@ mod tests {
|
||||
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
|
||||
use ide_core::{FileEdit, LanguageId};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let shared = root.join("shared.idx");
|
||||
let native = root.join("native.txt");
|
||||
std::fs::write(&shared, "old old").unwrap();
|
||||
std::fs::write(&native, "old").unwrap();
|
||||
// Exercise legacy verbatim index paths alongside ordinary native URI paths.
|
||||
let shared = std::fs::canonicalize(shared).unwrap();
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
|
||||
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
|
||||
let symbol = IndexedSymbol {
|
||||
@@ -1446,8 +1702,6 @@ mod tests {
|
||||
ranges: vec![range, other],
|
||||
}],
|
||||
};
|
||||
// Native file URIs have ordinary Windows paths; index snapshots retain
|
||||
// canonical verbatim paths. Both identify the same file and edit batch.
|
||||
let server_shared = dunce::simplified(&shared).to_path_buf();
|
||||
let server_native = dunce::simplified(&native).to_path_buf();
|
||||
#[cfg(windows)]
|
||||
@@ -1771,7 +2025,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(&root).unwrap();
|
||||
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
|
||||
title: "Create view".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
|
||||
@@ -9,6 +9,9 @@ use std::time::Duration;
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.refresh_run_configurations(&project.root);
|
||||
}
|
||||
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
|
||||
}
|
||||
|
||||
@@ -199,8 +202,48 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
let result = start_inner(host, window, config, on_output).await;
|
||||
if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) {
|
||||
plugins.run_operation_started(window, &operation, info.id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
) -> IdeResult<RunInfo> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if config.extras.target.is_none() {
|
||||
config.extras.target = ide_sdk::apply::run_target(&config, &project.settings.effective()).map(str::to_owned);
|
||||
}
|
||||
if config.detected
|
||||
&& config.id.starts_with(ide_run::EXTERNAL_PREFIX)
|
||||
&& let Some(plugins) = host.plugin_host()
|
||||
{
|
||||
let provider = plugins.run_detection();
|
||||
let binary =
|
||||
provider.launch(&config).map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?;
|
||||
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
provider
|
||||
.detect(&project.root)
|
||||
.into_iter()
|
||||
.find(|c| c.id == config.id)
|
||||
.ok_or_else(|| IdeError::not_found("plugin configuration no longer available"))?
|
||||
.command
|
||||
} else {
|
||||
binary.to_string_lossy().into_owned()
|
||||
};
|
||||
config.detected = false;
|
||||
}
|
||||
let mut sink = project.sink.clone();
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -210,6 +253,25 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
}
|
||||
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
if id.starts_with("plugin-target:") {
|
||||
let plugins =
|
||||
host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let root = project.root.clone();
|
||||
let id = id.to_owned();
|
||||
let config = host.blocking(move || plugins.prepare_run_target(&root, &id, &config)).await?;
|
||||
project.require_trust()?;
|
||||
return host.processes.start(
|
||||
&project.root,
|
||||
&config,
|
||||
&StartOptions {
|
||||
encoding: Some(settings.console.encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: Vec::new(),
|
||||
ruby_reporter: None,
|
||||
},
|
||||
sink,
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind != RunTargetKind::Ssh {
|
||||
// A local run of the docker CLI: the command is resolved in the container, and
|
||||
@@ -227,15 +289,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
&project.root,
|
||||
&config,
|
||||
&options,
|
||||
project.sink.clone(),
|
||||
sink.clone(),
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
|
||||
}
|
||||
let mut config = config;
|
||||
let (root, settings) = settings_for(host, &project, &mut config)?;
|
||||
@@ -286,7 +345,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
ruby_reporter: ruby_reporter_dir(host, &config),
|
||||
};
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
|
||||
let previous = sink;
|
||||
let root = project.root.clone();
|
||||
@@ -317,6 +375,9 @@ fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathB
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
|
||||
@@ -34,67 +34,12 @@ pub async fn start(
|
||||
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
|
||||
// ignored names are never searched.
|
||||
query.exclude.extend(project.search_excludes());
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let cancel = host.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let syntax = host.syntax();
|
||||
let summary = host
|
||||
.blocking(move || {
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
Some(ProjectFileSet::All) | None => true,
|
||||
};
|
||||
// Comments and string literals for the search context filter.
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
let found = syntax.literals(&language, text)?;
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.search_text_of(p),
|
||||
&cancel,
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
events(SearchEvent::File { file });
|
||||
},
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
total.elapsed_ms += s.elapsed_ms;
|
||||
total.limit_hit |= s.limit_hit;
|
||||
total.cancelled |= s.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
search_project(&project, &syntax, &query, &cancel, &|file| events(SearchEvent::File { file }))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
@@ -102,5 +47,69 @@ pub async fn start(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared Find in Files engine: project excludes are added by the caller.
|
||||
pub(crate) fn search_project(
|
||||
project: &crate::project::Project,
|
||||
syntax: &ide_language::Syntax,
|
||||
query: &SearchQuery,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
emit: &(dyn Fn(FileMatches) + Sync),
|
||||
) -> IdeResult<SearchSummary> {
|
||||
let root = &project.root;
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some();
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
_ => true,
|
||||
};
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = project.languages.detect(path, text.lines().next());
|
||||
Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) {
|
||||
let mut remaining = query.clone();
|
||||
let max = if query.max_matches == 0 { 10_000 } else { query.max_matches };
|
||||
remaining.max_matches = max.saturating_sub(total.matches).max(1);
|
||||
let summary = ide_search::search_in_context(
|
||||
dir,
|
||||
&remaining,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| project.documents.search_text_of(p),
|
||||
cancel,
|
||||
&|mut file| {
|
||||
if dir != root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
emit(file);
|
||||
},
|
||||
)?;
|
||||
total.files += summary.files;
|
||||
total.matches += summary.matches;
|
||||
total.elapsed_ms += summary.elapsed_ms;
|
||||
total.limit_hit |= summary.limit_hit || total.matches >= max;
|
||||
total.cancelled |= summary.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
+1373
-29
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
//! One-shot OAuth redirects, always bound on the desktop's IPv4 loopback.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::HostError;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Ipv4Addr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) trait Service: Send + Sync {
|
||||
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError>;
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError>;
|
||||
fn close(&self, plugin: &str, id: u64);
|
||||
fn clear(&self, plugin: &str);
|
||||
fn forget_window(&self, window: &str);
|
||||
}
|
||||
struct Entry {
|
||||
window: String,
|
||||
path: String,
|
||||
listener: Mutex<Option<TcpListener>>,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Loopbacks {
|
||||
next: AtomicU64,
|
||||
entries: Mutex<HashMap<(String, u64), Arc<Entry>>>,
|
||||
}
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
fn respond(stream: &mut TcpStream, ok: bool) {
|
||||
let body = if ok { "You can close this tab." } else { "Not found." };
|
||||
let status = if ok { "200 OK" } else { "404 Not Found" };
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
impl Loopbacks {
|
||||
fn remove_where(&self, predicate: impl Fn(&str, &Entry) -> bool) {
|
||||
self.entries.lock().retain(|(p, _), entry| {
|
||||
if !predicate(p, entry) {
|
||||
return true;
|
||||
}
|
||||
entry.cancel.cancel();
|
||||
entry.listener.lock().take();
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Service for Loopbacks {
|
||||
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError> {
|
||||
let path = path.trim_start_matches('/');
|
||||
if path.len() > 1024
|
||||
|| path.bytes().any(|b| !(b.is_ascii_alphanumeric() || b"/-._~".contains(&b)))
|
||||
|| path.split('/').any(|s| matches!(s, "." | ".."))
|
||||
{
|
||||
return Err(HostError::Invalid("loopback path must be a URL path without query or fragment".into()));
|
||||
}
|
||||
let mut entries = self.entries.lock();
|
||||
if entries.keys().filter(|(p, _)| p == plugin).count() >= 2 {
|
||||
return Err(HostError::Invalid("at most two open loopbacks per plugin".into()));
|
||||
}
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?;
|
||||
listener.set_nonblocking(true).map_err(failed)?;
|
||||
let uri = format!("http://127.0.0.1:{}/{path}", listener.local_addr().map_err(failed)?.port());
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
(plugin.into(), id),
|
||||
Arc::new(Entry {
|
||||
window: window.into(),
|
||||
path: format!("/{path}"),
|
||||
listener: Mutex::new(Some(listener)),
|
||||
cancel: CancellationToken::new(),
|
||||
}),
|
||||
);
|
||||
Ok((id, uri))
|
||||
}
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let entry = self
|
||||
.entries
|
||||
.lock()
|
||||
.get(&(plugin.into(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound("loopback".into()))?;
|
||||
let listener =
|
||||
entry.listener.lock().take().ok_or_else(|| HostError::Invalid("loopback already awaited".into()))?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
let result = (|| loop {
|
||||
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((mut stream, address)) => {
|
||||
if !address.ip().is_loopback() {
|
||||
continue;
|
||||
}
|
||||
stream.set_nonblocking(true).map_err(failed)?;
|
||||
let mut head = Vec::new();
|
||||
loop {
|
||||
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut buf = [0; 1024];
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 8192 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5))
|
||||
}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
}
|
||||
let line = std::str::from_utf8(&head).unwrap_or_default().lines().next().unwrap_or_default();
|
||||
let target = line.strip_prefix("GET ").and_then(|s| s.split_once(' ')).map(|(target, _)| target);
|
||||
let query = target.and_then(|target| {
|
||||
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||
(path == entry.path && head.len() <= 8192).then(|| query.to_owned())
|
||||
});
|
||||
stream.set_nonblocking(false).map_err(failed)?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(100))).map_err(failed)?;
|
||||
respond(&mut stream, query.is_some());
|
||||
if query.is_some() {
|
||||
return Ok(query);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
})();
|
||||
self.close(plugin, id);
|
||||
result
|
||||
}
|
||||
fn close(&self, plugin: &str, id: u64) {
|
||||
let entry = self.entries.lock().remove(&(plugin.into(), id));
|
||||
if let Some(entry) = entry {
|
||||
entry.cancel.cancel();
|
||||
entry.listener.lock().take();
|
||||
}
|
||||
}
|
||||
fn clear(&self, plugin: &str) {
|
||||
self.remove_where(|p, _| p == plugin || p.starts_with("remote:") && p.ends_with(&format!(":{plugin}")));
|
||||
}
|
||||
fn forget_window(&self, window: &str) {
|
||||
self.remove_where(|_, entry| entry.window == window);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn one_shot_raw_query_limits_timeout_and_cleanup() {
|
||||
let service = Arc::new(Loopbacks::default());
|
||||
let (id, uri) = service.begin("a", "main", "oauth/callback").unwrap();
|
||||
assert!(uri.starts_with("http://127.0.0.1:"));
|
||||
let (other, _) = service.begin("a", "main", "").unwrap();
|
||||
assert!(service.begin("a", "main", "third").is_err());
|
||||
assert_eq!(service.wait("a", other, Duration::ZERO, &CancellationToken::new()).unwrap(), None);
|
||||
let host = uri.strip_prefix("http://").unwrap().split('/').next().unwrap().to_owned();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut stream = TcpStream::connect(host).unwrap();
|
||||
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=x HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
|
||||
let mut answer = String::new();
|
||||
stream.read_to_string(&mut answer).unwrap();
|
||||
assert!(answer.contains("You can close this tab"));
|
||||
});
|
||||
assert_eq!(
|
||||
service.wait("a", id, Duration::from_secs(2), &CancellationToken::new()).unwrap(),
|
||||
Some("code=a%2Bb&state=x".into())
|
||||
);
|
||||
worker.join().unwrap();
|
||||
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
|
||||
let (id, _) = service.begin("a", "main", "callback").unwrap();
|
||||
service.forget_window("main");
|
||||
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Database ownership stays on the project host; credentials never pass back to guests.
|
||||
use super::api::PluginApi;
|
||||
use super::{PluginHost, WindowRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult};
|
||||
use ide_db::{DataSource, DbDriver, DbProperty};
|
||||
use ide_plugin::{HostError, PluginDataSource};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct OwnedProject {
|
||||
root: PathBuf,
|
||||
sources: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub(super) fn settings(source: PluginDataSource) -> Result<DataSource, HostError> {
|
||||
let bad = || HostError::Invalid("invalid non-secret database source settings".into());
|
||||
if source.id.is_empty()
|
||||
|| source.id.len() > 128
|
||||
|| source.name.trim().is_empty()
|
||||
|| source.name.len() > 256
|
||||
|| source.id.chars().chain(source.name.chars()).any(char::is_control)
|
||||
|| source.options.len() > 128
|
||||
|| [&source.host, &source.database, &source.user, &source.url]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|v| v.len() > 16_384 || v.contains('\0'))
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let driver = match source.driver.as_str() {
|
||||
"sqlite" => DbDriver::Sqlite,
|
||||
"postgres" => DbDriver::Postgres,
|
||||
"mysql" => DbDriver::Mysql,
|
||||
"sqlServer" => DbDriver::SqlServer,
|
||||
"clickhouse" => DbDriver::Clickhouse,
|
||||
"oracle" => DbDriver::Oracle,
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
let secret = |key: &str| {
|
||||
let key = key.to_ascii_lowercase();
|
||||
["password", "passwd", "pwd", "token", "secret", "credential"].iter().any(|s| key.contains(s))
|
||||
};
|
||||
if source.options.iter().any(|(k, v)| {
|
||||
k.is_empty() || k.len() > 256 || v.len() > 16_384 || k.contains('\0') || v.contains('\0') || secret(k)
|
||||
}) {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut out = DataSource {
|
||||
name: source.name,
|
||||
driver,
|
||||
host: source.host.unwrap_or_else(|| "localhost".into()),
|
||||
port: source.port,
|
||||
database: source.database.unwrap_or_default(),
|
||||
user: source.user.unwrap_or_default(),
|
||||
save_password: true,
|
||||
properties: source.options.into_iter().map(|(name, value)| DbProperty { name, value }).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
if driver == DbDriver::Sqlite {
|
||||
out.file = Some(out.database.clone().into());
|
||||
}
|
||||
if let Some(url) = source.url.filter(|u| !u.is_empty()) {
|
||||
if url.contains(';') {
|
||||
return Err(bad());
|
||||
}
|
||||
let parsed = ide_db::source::parse_url(&url).map_err(|_| bad())?;
|
||||
if parsed.password.is_some() || parsed.params.iter().any(|(k, _)| secret(k)) {
|
||||
return Err(bad());
|
||||
}
|
||||
// Case-insensitive URL credential keys must not bypass the parser's lowercase password key.
|
||||
if let Some((_, query)) = url.split_once('?') {
|
||||
for part in query.split('&') {
|
||||
let key = part.split('=').next().unwrap_or_default();
|
||||
// Parsing a synthetic URL decodes percent-encoded parameter names as well.
|
||||
let probe = ide_db::source::parse_url(&format!("postgres://host/db?{key}=x")).map_err(|_| bad())?;
|
||||
if probe.password.is_some() || probe.params.iter().any(|(k, _)| secret(k)) {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.url_only = true;
|
||||
out.url = url;
|
||||
out.resolved().map_err(|_| bad())?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
pub(super) fn database_host(&self, root: &Path) -> Result<(Arc<crate::Host>, Arc<ide_db::DbManager>), HostError> {
|
||||
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
|
||||
if !workspace.projects().iter().any(|r| r == root) {
|
||||
return Err(HostError::NotFound("project is not open".into()));
|
||||
}
|
||||
if !workspace.trusted(root) {
|
||||
return Err(HostError::Denied("project is not trusted".into()));
|
||||
}
|
||||
ide_plugin::resolve_new_in_root(root, ".openidea/data-sources.toml").map_err(HostError::Invalid)?;
|
||||
let host = workspace
|
||||
.database_host()
|
||||
.ok_or_else(|| HostError::Invalid("Database subsystem is unavailable on this host".into()))?;
|
||||
let db = host.projects().into_iter().find(|p| p.root == root).ok_or(HostError::Cancelled)?.db.clone();
|
||||
Ok((host, db))
|
||||
}
|
||||
pub(super) fn remember_database_project(&self, root: &Path, id: &str) -> Result<(), HostError> {
|
||||
let file = self.shared.data_dir.join(".database-projects").join(format!("{}.json", self.pkg.id()));
|
||||
let mut projects: Vec<OwnedProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(e.to_string()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => vec![],
|
||||
Err(e) => return Err(HostError::Failed(e.to_string())),
|
||||
};
|
||||
if !projects.iter().any(|p| p.root == root) {
|
||||
projects.push(OwnedProject { root: root.into(), sources: Default::default() });
|
||||
}
|
||||
let project = projects.iter_mut().find(|p| p.root == root).unwrap();
|
||||
if project.sources.insert(id.into()) {
|
||||
std::fs::create_dir_all(file.parent().unwrap()).map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&file, &serde_json::to_vec(&projects).unwrap()).map_err(error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn connect_database(
|
||||
&self,
|
||||
root: &Path,
|
||||
client: &str,
|
||||
id: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let (_, db) = self.database_host(root)?;
|
||||
if self.shared.project_of(client).as_deref() != Some(root) {
|
||||
return Err(HostError::Invalid("requires this project's invoking window".into()));
|
||||
}
|
||||
let source = db.plugin_source(self.pkg.id(), id).map_err(error)?;
|
||||
self.window_request(client, WindowRequest::DatabaseConnect { id: source.id }, cancel, timeout).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn changed(host: &crate::Host, root: &Path) {
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
project.sink.emit(IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
}
|
||||
impl PluginHost {
|
||||
pub(super) fn remove_database_sources(&self, plugin: &str) -> IdeResult<()> {
|
||||
let file = self.shared.data_dir.join(".database-projects").join(format!("{plugin}.json"));
|
||||
let projects: Vec<OwnedProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let host = self
|
||||
.shared
|
||||
.workspace()
|
||||
.and_then(|w| w.database_host())
|
||||
.ok_or_else(|| IdeError::unavailable("Database subsystem is unavailable on this host"))?;
|
||||
for project in projects {
|
||||
let root = project.root;
|
||||
let removed = ide_db::plugin::remove_all(&root, plugin)?;
|
||||
let store = ide_db::secrets::SecretStore::new(host.data_dir.join("db-passwords.json"), &root);
|
||||
let ids: std::collections::BTreeSet<_> = project
|
||||
.sources
|
||||
.iter()
|
||||
.map(|id| ide_db::plugin::source_id(plugin, id))
|
||||
.chain(removed.into_iter().map(|source| source.id))
|
||||
.collect();
|
||||
for id in ids {
|
||||
store.delete(&id);
|
||||
store.delete(&ide_db::secrets::ssh_key(&id));
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
host.runtime.block_on(project.db.forget_plugin_source(&id));
|
||||
}
|
||||
}
|
||||
changed(&host, &root);
|
||||
}
|
||||
std::fs::remove_file(file)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn error(e: IdeError) -> HostError {
|
||||
use ide_core::error::ErrorKind;
|
||||
match e.kind {
|
||||
ErrorKind::NotFound => HostError::NotFound(e.message),
|
||||
ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
ErrorKind::PermissionDenied => HostError::Denied(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn source() -> PluginDataSource {
|
||||
PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() }
|
||||
}
|
||||
#[test]
|
||||
fn rejects_secrets_without_echoing_them_and_documents_every_driver_id() {
|
||||
for driver in ["sqlite", "postgres", "mysql", "sqlServer", "clickhouse", "oracle"] {
|
||||
assert!(settings(PluginDataSource { driver: driver.into(), ..source() }).is_ok(), "{driver}");
|
||||
}
|
||||
for url in [
|
||||
"postgres://user:top-secret@host/db",
|
||||
"postgres://host/db?password=top-secret",
|
||||
"postgres://host/db?PASSWORD=top-secret",
|
||||
"postgres://host/db?to%6ben=top-secret",
|
||||
] {
|
||||
let error = settings(PluginDataSource { url: Some(url.into()), ..source() }).unwrap_err();
|
||||
assert!(!error.to_string().contains("top-secret"));
|
||||
}
|
||||
for key in ["password", "accessToken", "client_secret", "PWD"] {
|
||||
assert!(
|
||||
settings(PluginDataSource { options: vec![(key.into(), "top-secret".into())], ..source() }).is_err()
|
||||
);
|
||||
}
|
||||
let source = settings(PluginDataSource {
|
||||
url: Some("postgres://reader@host:5433/db?sslmode=require".into()),
|
||||
options: vec![("application_name".into(), "Example".into())],
|
||||
..source()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(source.resolved().unwrap().host, "host");
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,9 @@ impl Diagnostics {
|
||||
doc.cancel.cancel();
|
||||
doc.cancel = CancellationToken::new();
|
||||
let Some(host) = editor_plugin_host() else { return };
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty()
|
||||
&& !host.providers.any(ProviderKind::Injections)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Register only once per project incarnation; no project is retained by the host.
|
||||
@@ -113,6 +115,7 @@ impl Diagnostics {
|
||||
snapshot.text = current.text;
|
||||
drop(documents);
|
||||
|
||||
let injections = host.providers.injection_generations(&snapshot.language);
|
||||
let generations = host.providers.diagnostic_generations(&snapshot.language);
|
||||
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
|
||||
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
|
||||
@@ -124,6 +127,11 @@ impl Diagnostics {
|
||||
if state.closed || cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if injections != host.providers.injection_generations(&snapshot.language)
|
||||
|| injections.keys().any(|id| lifecycle_generations.get(id) != state.generations.get(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let matching: HashSet<_> = host
|
||||
.diagnostic_plugins(Some(&root), &snapshot.language)
|
||||
.into_iter()
|
||||
@@ -224,6 +232,13 @@ pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
|
||||
impl PluginHost {
|
||||
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
|
||||
// Clone before taking a project's state lock (edits register in the other order).
|
||||
let injector = self.get(plugin).is_ok_and(|p| {
|
||||
p.manifest().declarations("providers").iter().any(|d| {
|
||||
d.get("kinds")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|v| v.iter().any(|k| k.as_str() == Some("injections")))
|
||||
})
|
||||
});
|
||||
let projects = self.diagnostic_projects.lock().clone();
|
||||
for (state, sink) in projects {
|
||||
if let Some(state) = state.upgrade() {
|
||||
@@ -231,7 +246,15 @@ impl PluginHost {
|
||||
// A completed answer can still be waiting for another provider.
|
||||
*state.generations.entry(plugin.to_owned()).or_default() += 1;
|
||||
for doc in state.documents.values_mut() {
|
||||
if doc.sources.remove(plugin) {
|
||||
if injector {
|
||||
for source in doc.sources.drain() {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{source}"),
|
||||
items: vec![],
|
||||
});
|
||||
}
|
||||
} else if doc.sources.remove(plugin) {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{plugin}"),
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validates_entire_rooted_batch_with_utf16_positions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let file = root.join("routes.rb");
|
||||
std::fs::write(&file, "😀x\n").unwrap();
|
||||
let good = endpoint(file.clone());
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Window-owned plugin operations. Instance reclamation does not end them.
|
||||
use super::api::{PluginApi, PromptEntry, Waiter};
|
||||
use super::{CommandAnswer, EventQueue, PluginEvent, PluginHost, WindowRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_plugin::{ContentTab, ContentTabReply, HostError, WorkbenchEvent};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) struct Operation {
|
||||
pub plugin: String,
|
||||
pub client: String,
|
||||
pub kind: OperationKind,
|
||||
pub root: Option<std::path::PathBuf>,
|
||||
pub started: bool,
|
||||
pub closed: bool,
|
||||
pub run: Option<ide_core::RunId>,
|
||||
pub terminal: Option<(std::sync::Weak<crate::Host>, ide_core::TerminalId)>,
|
||||
}
|
||||
|
||||
pub(super) enum OperationKind {
|
||||
Run { config: String, configuration: String, program: String },
|
||||
Terminal { key: String, command: ide_plugin::ProcessCommand },
|
||||
}
|
||||
|
||||
pub(super) fn label(value: &str, max: usize) -> Result<(), HostError> {
|
||||
if value.is_empty() || value.encode_utf16().count() > max || value.chars().any(char::is_control) {
|
||||
return Err(HostError::Invalid(format!("expected 1-{max} characters without controls")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn body(value: &str) -> Result<(), HostError> {
|
||||
if value.encode_utf16().count() > 1 << 20 || value.contains('\0') {
|
||||
return Err(HostError::Invalid("content too large or contains NUL".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_tab(tab: &ContentTab) -> Result<(), HostError> {
|
||||
label(&tab.key, 128)?;
|
||||
label(&tab.title, 256)?;
|
||||
body(&tab.content)?;
|
||||
if let Some(name) = &tab.file_name {
|
||||
label(name, 256)?;
|
||||
if name.contains(['/', '\\']) {
|
||||
return Err(HostError::Invalid("file-name cannot contain separators".into()));
|
||||
}
|
||||
}
|
||||
if let Some(language) = &tab.language {
|
||||
label(language, 64)?;
|
||||
}
|
||||
if tab.actions.len() > 16 || tab.actions.iter().filter(|a| a.save).count() > 1 {
|
||||
return Err(HostError::Invalid("at most 16 actions and one Save action".into()));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for action in &tab.actions {
|
||||
label(&action.id, 64)?;
|
||||
label(&action.label, 128)?;
|
||||
if !ids.insert(&action.id) {
|
||||
return Err(HostError::Invalid("duplicate action id".into()));
|
||||
}
|
||||
if let Some(icon) = &action.icon {
|
||||
label(icon, 128)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
if let Some(content) = &reply.content {
|
||||
body(content)?;
|
||||
}
|
||||
if reply.status.as_ref().is_some_and(|s| s.len() > 512) || reply.error.as_ref().is_some_and(|s| s.len() > 1024) {
|
||||
return Err(HostError::Invalid("content status or error too long".into()));
|
||||
}
|
||||
if let Some(diff) = &reply.diff {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
if let Some(title) = &diff.title {
|
||||
label(title, 256)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
pub(super) fn new_operation(&self, client: &str, kind: OperationKind) -> Result<String, HostError> {
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if closed.contains(client) || blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if operations.values().filter(|o| o.plugin == self.pkg.id()).count() >= 32 {
|
||||
return Err(HostError::Invalid("at most 32 active operations".into()));
|
||||
}
|
||||
let id = format!("plugin-op-{}", self.shared.next_prompt.fetch_add(1, Ordering::Relaxed));
|
||||
operations.insert(
|
||||
id.clone(),
|
||||
Operation {
|
||||
plugin: self.pkg.id().into(),
|
||||
client: client.into(),
|
||||
kind,
|
||||
root: self.shared.project_of(client),
|
||||
started: false,
|
||||
closed: false,
|
||||
terminal: None,
|
||||
run: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
pub(super) fn window_request(
|
||||
&self,
|
||||
client: &str,
|
||||
request: WindowRequest,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if closed.contains(client) || blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(
|
||||
id,
|
||||
PromptEntry { plugin: self.pkg.id().into(), client: client.into(), answer: Waiter::Command(tx) },
|
||||
);
|
||||
}
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::WindowRequest { plugin_id: self.pkg.id().into(), request_id: id.to_string(), request },
|
||||
);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(CommandAnswer::Value(value)) => return Ok(value),
|
||||
Ok(CommandAnswer::NotFound) => return Err(HostError::NotFound("window service unavailable".into())),
|
||||
Ok(CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if !cancel.is_cancelled() && Instant::now() < deadline => {}
|
||||
Err(_) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// The launcher alone may finish its operation; duplicate and foreign reports are ignored.
|
||||
pub fn finish_operation(&self, client: &str, id: &str, status: Option<i32>, stopped: bool) -> bool {
|
||||
let operation = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if !operations.get(id).is_some_and(|op| op.client == client) {
|
||||
return false;
|
||||
}
|
||||
operations.remove(id).unwrap()
|
||||
};
|
||||
let event = match operation.kind {
|
||||
OperationKind::Run { config, .. } => {
|
||||
WorkbenchEvent::RunExited(ide_plugin::RunExited { id: id.into(), config, status, killed: stopped })
|
||||
}
|
||||
OperationKind::Terminal { key, .. } => WorkbenchEvent::TerminalExited(ide_plugin::TerminalExited {
|
||||
id: id.into(),
|
||||
key,
|
||||
status,
|
||||
closed: stopped,
|
||||
}),
|
||||
};
|
||||
self.post_owned_event(&operation.plugin, client, event);
|
||||
true
|
||||
}
|
||||
/// Content actions bypass subscriptions, just like completions of owned operations.
|
||||
pub fn content_tab_action(
|
||||
&self,
|
||||
client: &str,
|
||||
plugin: &str,
|
||||
action: ide_plugin::ContentTabAction,
|
||||
) -> IdeResult<()> {
|
||||
let tabs = self.shared.content_tabs.lock();
|
||||
let tab = tabs
|
||||
.get(&(plugin.into(), client.into(), action.key.clone()))
|
||||
.ok_or_else(|| IdeError::not_found("unknown content tab"))?;
|
||||
if !tab.actions.iter().any(|a| a.id == action.action) {
|
||||
return Err(IdeError::invalid("unknown content action"));
|
||||
}
|
||||
body(&action.content).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
label(&action.request_id, 128).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
drop(tabs);
|
||||
let mut pending = self.shared.content_requests.lock();
|
||||
pending.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(15));
|
||||
if pending.keys().filter(|(p, w, _)| p == plugin && w == client).count() >= 32 {
|
||||
return Err(IdeError::invalid("too many pending content actions"));
|
||||
}
|
||||
pending.insert((plugin.into(), client.into(), action.request_id.clone()), (action.key.clone(), Instant::now()));
|
||||
drop(pending);
|
||||
self.post_owned_event(plugin, client, WorkbenchEvent::ContentTabAction(action));
|
||||
Ok(())
|
||||
}
|
||||
fn post_owned_event(&self, plugin: &str, client: &str, event: WorkbenchEvent) {
|
||||
if self.shared.client_closed(client) {
|
||||
return;
|
||||
}
|
||||
self.queues
|
||||
.lock()
|
||||
.entry((plugin.into(), Some(client.into())))
|
||||
.or_insert_with(|| EventQueue { since: Instant::now(), events: Vec::new(), files_source: None })
|
||||
.events
|
||||
.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic configurations opt in with marker files, just like static detectors.
|
||||
pub(super) fn configurations_declaration(
|
||||
pkg: &ide_extension::Package,
|
||||
value: toml::Value,
|
||||
) -> Result<ide_extension::UiDecl, String> {
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
files: Vec<String>,
|
||||
}
|
||||
pkg.manifest.needs_api("contributes.runConfigurations", 4)?;
|
||||
let decl: Decl = ide_extension::decode("runConfigurations", value)?;
|
||||
if pkg.manifest.wasm.is_none() || decl.files.is_empty() || decl.files.len() > 16 {
|
||||
return Err("runConfigurations needs code and 1-16 marker files".into());
|
||||
}
|
||||
for file in &decl.files {
|
||||
ide_extension::check_relative(std::path::Path::new(file), "files")?;
|
||||
}
|
||||
Ok(ide_extension::UiDecl::new(
|
||||
serde_json::to_string(&decl.files).unwrap(),
|
||||
"Run configurations",
|
||||
serde_json::to_value(decl).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Refreshes configuration exports on the same listing path as static detectors.
|
||||
pub fn refresh_run_configurations(&self, root: &std::path::Path) {
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
|
||||
return;
|
||||
}
|
||||
let owners: std::collections::BTreeSet<_> = self
|
||||
.ui
|
||||
.items("runConfigurations", None)
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
d.decl["files"]
|
||||
.as_array()
|
||||
.is_some_and(|files| files.iter().filter_map(|f| f.as_str()).any(|f| root.join(f).is_file()))
|
||||
})
|
||||
.map(|d| d.plugin_id)
|
||||
.collect();
|
||||
self.shared.run_configurations.write().retain(|(p, r), _| r != root || owners.contains(p));
|
||||
for plugin in owners {
|
||||
let project = root.to_path_buf();
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(project.clone()), ..Default::default() };
|
||||
let result = self.code_call(&plugin, invocation, "run-configurations", |rt, ctx| {
|
||||
rt.run_configurations(
|
||||
ctx.workspace_root.unwrap().to_string_lossy().into_owned(),
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
});
|
||||
let Ok(items) = result else { continue };
|
||||
if let Err(error) = validate_configurations(root, &items) {
|
||||
tracing::warn!(%plugin, %error, "invalid run configurations");
|
||||
continue;
|
||||
}
|
||||
self.shared.run_configurations.write().insert((plugin, project), items);
|
||||
}
|
||||
}
|
||||
/// Resolves only an owned plugin test configuration in the client's trusted project.
|
||||
fn test_configuration(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
|
||||
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
|
||||
return Err(IdeError::invalid("project is not trusted"));
|
||||
}
|
||||
let Some(rest) = config.strip_prefix(ide_run::EXTERNAL_PREFIX) else { return Ok(None) };
|
||||
let Some((owner, id)) = rest.split_once(':') else { return Ok(None) };
|
||||
let configuration = self
|
||||
.shared
|
||||
.workspace()
|
||||
.ok_or_else(IdeError::cancelled)?
|
||||
.run_configuration(client, owner, id)
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !configuration.is_test || configuration.extras.params.get("framework").map(String::as_str) != Some("plugin")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some((root, owner.to_owned(), id.to_owned())))
|
||||
}
|
||||
|
||||
/// Derives replacement argv under the parser's soft/hard call budgets.
|
||||
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
|
||||
if tests.is_empty()
|
||||
|| tests.len() > 10000
|
||||
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|
||||
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
|
||||
let plugin = self.get(&owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
|
||||
let hard = self.limits.edit_hard;
|
||||
let started = Instant::now();
|
||||
let result = actor.call(Some(self.limits.edit_soft), move |rt| rt.select_tests(id, tests, invocation, hard));
|
||||
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
|
||||
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
|
||||
}
|
||||
|
||||
/// Parses a bounded batch under the editor-provider soft/hard budgets.
|
||||
pub fn parse_test_results(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
chunk: String,
|
||||
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
|
||||
if chunk.len() > 64 << 10 {
|
||||
return Err(IdeError::invalid("test output batch too large"));
|
||||
}
|
||||
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
|
||||
let plugin = self.get(&owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(root.clone()), ..Default::default() };
|
||||
let (id, hard) = (id.to_owned(), self.limits.edit_hard);
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
actor.call(Some(self.limits.edit_soft), move |rt| rt.parse_test_results(id, chunk, invocation, hard));
|
||||
let mut events = self.finish_provider_call(&plugin, &actor, started, result);
|
||||
if let Some(events) = &mut events {
|
||||
if serde_json::to_vec(&*events).is_ok_and(|b| b.len() > 4 << 20) {
|
||||
return Ok(None);
|
||||
}
|
||||
for event in events {
|
||||
let location = match event {
|
||||
ide_plugin::PluginTestEvent::SuiteStarted(v) | ide_plugin::PluginTestEvent::TestStarted(v) => {
|
||||
v.location.as_mut()
|
||||
}
|
||||
ide_plugin::PluginTestEvent::Failed(v) => v.location.as_mut(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(location) = location {
|
||||
let Ok(path) = ide_plugin::resolve_new_in_root(&root, &location.path.to_string_lossy()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if location.range.end < location.range.start {
|
||||
return Ok(None);
|
||||
}
|
||||
location.path = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_configurations(
|
||||
root: &std::path::Path,
|
||||
items: &[ide_plugin::PluginRunConfiguration],
|
||||
) -> Result<(), HostError> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
if items.len() > 1000 || serde_json::to_vec(items).is_ok_and(|b| b.len() > 4 << 20) {
|
||||
return Err(HostError::Invalid("too many run configurations".into()));
|
||||
}
|
||||
for c in items {
|
||||
ide_extension::validate_item_id(&c.id, "run configuration id").map_err(HostError::Invalid)?;
|
||||
label(&c.name, 128)?;
|
||||
if !ids.insert(&c.id)
|
||||
|| c.program.is_empty()
|
||||
|| c.program.len() > 256
|
||||
|| c.program.contains(char::is_whitespace)
|
||||
|| c.args.len() > 256
|
||||
|| c.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|
||||
{
|
||||
return Err(HostError::Invalid("invalid run configuration".into()));
|
||||
}
|
||||
if let Some(cwd) = &c.cwd {
|
||||
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Resolves and rechecks the pin immediately before starting a keyed terminal's PTY.
|
||||
pub fn spawn_terminal_session(
|
||||
self: &std::sync::Arc<Self>,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
client: &str,
|
||||
operation: &str,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let (plugin, command) = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
let op = operations
|
||||
.get_mut(operation)
|
||||
.filter(|op| op.client == client)
|
||||
.ok_or_else(|| IdeError::not_found("unknown terminal operation"))?;
|
||||
if op.started || op.closed || op.root != self.shared.project_of(client) {
|
||||
return Err(IdeError::invalid("terminal operation already started or project changed"));
|
||||
}
|
||||
let OperationKind::Terminal { command, .. } = &op.kind else {
|
||||
return Err(IdeError::invalid("not a terminal operation"));
|
||||
};
|
||||
op.started = true;
|
||||
(op.plugin.clone(), command.clone())
|
||||
};
|
||||
let options = super::web::WebPtyCommand {
|
||||
program: command.program,
|
||||
args: command.args,
|
||||
env: command.env.into_iter().collect(),
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
let weak = Arc::downgrade(self);
|
||||
let (id, window) = (operation.to_owned(), client.to_owned());
|
||||
let completion = Arc::new(move |status| {
|
||||
if let Some(plugins) = weak.upgrade() {
|
||||
let closed = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
|
||||
plugins.finish_operation(&window, &id, status, closed);
|
||||
}
|
||||
});
|
||||
let result = self.spawn_plugin_pty(host, &plugin, client, output, Some(completion), || {
|
||||
self.pty_options(&plugin, client, options)
|
||||
});
|
||||
if let Ok(info) = &result {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if let Some(op) = operations.get_mut(operation) {
|
||||
op.terminal = Some((Arc::downgrade(host), info.id));
|
||||
if op.closed {
|
||||
let _ = host.terminals.kill(info.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.is_err()
|
||||
&& let Some(op) = self.shared.operations.lock().get_mut(operation)
|
||||
{
|
||||
op.started = false;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Closing a tab marks the operation before killing its PTY. The real exit emits the event.
|
||||
pub fn close_terminal_session(&self, client: &str, id: &str) -> IdeResult<()> {
|
||||
let terminal = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
let Some(op) = operations
|
||||
.get_mut(id)
|
||||
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Terminal { .. }))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
op.closed = true;
|
||||
op.terminal.clone()
|
||||
};
|
||||
if let Some((host, id)) = terminal
|
||||
&& let Some(host) = host.upgrade()
|
||||
{
|
||||
host.terminals.kill(id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Rechecks the owned configuration's program before Debug and every IDE rerun.
|
||||
pub fn prepare_debug_run(&self, client: &str, mut config: ide_run::RunConfig) -> IdeResult<ide_run::RunConfig> {
|
||||
let Some((plugin, id)) = config.id.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':'))
|
||||
else {
|
||||
return Err(IdeError::invalid("not a plugin configuration"));
|
||||
};
|
||||
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
|
||||
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if owned.debug_adapter.is_none() {
|
||||
return Err(IdeError::invalid("this configuration has no debugger"));
|
||||
}
|
||||
if config.extras.target.is_none() {
|
||||
config.extras.target = owned.extras.target.clone();
|
||||
}
|
||||
if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("debugging this target is not supported"));
|
||||
}
|
||||
config.command = self
|
||||
.program_for_project(plugin, &owned.command, self.shared.project_of(client).as_deref())?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
config.debug_adapter = owned.debug_adapter;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal launch metadata; it is ignored after the original operation finishes.
|
||||
pub(super) const RUN_OPERATION: &str = "plugin.operation";
|
||||
impl PluginHost {
|
||||
pub(crate) fn run_operation_sink(
|
||||
self: &Arc<Self>,
|
||||
client: &str,
|
||||
config: &mut ide_run::RunConfig,
|
||||
previous: Arc<dyn ide_core::EventSink>,
|
||||
) -> IdeResult<Arc<dyn ide_core::EventSink>> {
|
||||
let Some(id) = config.extras.params.get(RUN_OPERATION).cloned() else { return Ok(previous) };
|
||||
let origin = self.shared.operations.lock().get(&id).and_then(|op| {
|
||||
if op.client != client {
|
||||
return None;
|
||||
}
|
||||
let OperationKind::Run { configuration, program, .. } = &op.kind else { return None };
|
||||
(configuration == &config.id).then(|| (op.plugin.clone(), op.root.clone(), program.clone()))
|
||||
});
|
||||
let Some((plugin, root, program)) = origin else { return Ok(previous) };
|
||||
let binary = self.program_for_project(&plugin, &program, root.as_deref())?;
|
||||
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
program
|
||||
} else {
|
||||
binary.to_string_lossy().into_owned()
|
||||
};
|
||||
config.detected = false;
|
||||
let (weak, client) = (Arc::downgrade(self), client.to_owned());
|
||||
Ok(Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if let ide_core::IdeEvent::RunExited { code, .. } = &event
|
||||
&& let Some(plugins) = weak.upgrade()
|
||||
{
|
||||
let stopped = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
|
||||
plugins.finish_operation(&client, &id, *code, stopped);
|
||||
}
|
||||
previous.emit(event);
|
||||
}))
|
||||
}
|
||||
pub(crate) fn run_operation_started(&self, client: &str, operation: &str, run: ide_core::RunId) {
|
||||
if let Some(op) = self
|
||||
.shared
|
||||
.operations
|
||||
.lock()
|
||||
.get_mut(operation)
|
||||
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Run { .. }))
|
||||
{
|
||||
op.run = Some(run);
|
||||
}
|
||||
}
|
||||
pub(crate) fn run_operation_stopped(&self, run: ide_core::RunId) {
|
||||
for op in self.shared.operations.lock().values_mut().filter(|op| op.run == Some(run)) {
|
||||
op.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ pub async fn plugin_navigation_index(
|
||||
&project.root,
|
||||
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some(client.clone()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: None,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Embedded plugin-provider documents preserve the host's UTF-16 coordinates.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::Injection};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Allowed output ranges for one virtual document; the host document is unrestricted.
|
||||
pub(super) struct Bounds(Option<Vec<Range>>);
|
||||
impl Bounds {
|
||||
pub fn unrestricted() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
pub fn contains(&self, range: Range) -> bool {
|
||||
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start <= range.start && range.end <= r.end))
|
||||
}
|
||||
pub fn lines(&self, start: u32, end: u32) -> bool {
|
||||
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start.line <= start && end <= r.end.line))
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub fn provide_injections(&self, doc: &ProviderDocument) -> Vec<Injection> {
|
||||
if !self.providers.any(ProviderKind::Injections) {
|
||||
return vec![];
|
||||
}
|
||||
if doc.text.len() > super::navigation::MAX_DOCUMENT_BYTES {
|
||||
return Vec::new();
|
||||
}
|
||||
let text = ide_text::Document::new(&doc.text);
|
||||
let d = Arc::new(doc.clone());
|
||||
let mut items: Vec<_> = self
|
||||
.document_calls(ProviderKind::Injections, doc, move |rt, t| rt.provide_injections(&d, t))
|
||||
.into_iter()
|
||||
.flat_map(|(_, v)| v)
|
||||
.filter(|i| {
|
||||
!i.range.is_empty()
|
||||
&& super::language_features::valid_range_in(&text, i.range)
|
||||
&& i.language != doc.language
|
||||
&& !i.language.is_empty()
|
||||
&& i.language.len() <= 64
|
||||
&& i.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
|
||||
})
|
||||
.collect();
|
||||
items.sort_by_key(|i| i.range);
|
||||
let mut end = Pos::default();
|
||||
items.retain(|i| {
|
||||
if i.range.start < end {
|
||||
return false;
|
||||
}
|
||||
end = i.range.end;
|
||||
true
|
||||
});
|
||||
items.truncate(1000);
|
||||
items
|
||||
}
|
||||
|
||||
pub(super) fn feature_documents(&self, doc: &ProviderDocument, at: Option<Pos>) -> Vec<(ProviderDocument, Bounds)> {
|
||||
let injections = self.provide_injections(doc);
|
||||
let mut languages = Vec::new();
|
||||
for i in &injections {
|
||||
if at.is_some_and(|p| p < i.range.start || p >= i.range.end) || languages.contains(&i.language) {
|
||||
continue;
|
||||
}
|
||||
languages.push(i.language.clone());
|
||||
if languages.len() == 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut docs = Vec::new();
|
||||
for language in languages {
|
||||
let ranges = injections.iter().filter(|i| i.language == language).map(|i| i.range).collect();
|
||||
let text = virtual_text(doc, &injections, &language);
|
||||
docs.push((ProviderDocument { language, text, ..doc.clone() }, Bounds(Some(ranges))));
|
||||
}
|
||||
docs.push((doc.clone(), Bounds(None)));
|
||||
docs
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], language: &str) -> String {
|
||||
let ranges: Vec<_> = ranges.iter().filter(|i| i.language == language).map(|i| i.range).collect();
|
||||
let mut index = 0;
|
||||
let mut at = Pos::default();
|
||||
let mut text = String::with_capacity(doc.text.len());
|
||||
for c in doc.text.chars() {
|
||||
while index < ranges.len() && ranges[index].end <= at {
|
||||
index += 1;
|
||||
}
|
||||
let inside = ranges.get(index).is_some_and(|r| r.start <= at && at < r.end);
|
||||
if inside || c == '\n' || c == '\r' {
|
||||
text.push(c);
|
||||
} else {
|
||||
for _ in 0..c.len_utf16() {
|
||||
text.push(' ');
|
||||
}
|
||||
}
|
||||
if c == '\n' {
|
||||
at.line += 1;
|
||||
at.col = 0;
|
||||
} else {
|
||||
at.col += c.len_utf16() as u32;
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn virtual_documents_preserve_utf16_columns_and_newlines() {
|
||||
let d = ProviderDocument {
|
||||
path: "/p/a".into(),
|
||||
language: "host".into(),
|
||||
version: 7,
|
||||
text: "😀 sql\n outside\n sql".into(),
|
||||
};
|
||||
let ranges = vec![
|
||||
Injection { range: Range::new(Pos::new(0, 3), Pos::new(0, 6)), language: "sql".into() },
|
||||
Injection { range: Range::new(Pos::new(2, 1), Pos::new(2, 4)), language: "sql".into() },
|
||||
];
|
||||
let text = virtual_text(&d, &ranges, "sql");
|
||||
assert_eq!(text, " sql\n \n sql");
|
||||
assert_eq!(text.encode_utf16().count(), d.text.encode_utf16().count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Plugin-owned local byte streams, closed on resource drop and plugin shutdown.
|
||||
use super::api::net_runtime;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::HostError;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
|
||||
struct Connection {
|
||||
stream: tokio::sync::Mutex<Box<dyn Stream>>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
impl Drop for Connection {
|
||||
fn drop(&mut self) {
|
||||
self.closed.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Connections {
|
||||
next: AtomicU64,
|
||||
entries: Mutex<HashMap<(String, u64), Arc<Connection>>>,
|
||||
}
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
impl Connections {
|
||||
pub fn connect(
|
||||
&self,
|
||||
plugin: &str,
|
||||
name: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<u64, HostError> {
|
||||
let check = |entries: &HashMap<(String, u64), Arc<Connection>>| {
|
||||
if entries.keys().filter(|(p, _)| p == plugin).count() >= 8 {
|
||||
Err(HostError::Invalid("at most eight IPC connections per plugin".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
check(&self.entries.lock())?;
|
||||
let stream: Box<dyn Stream> = net_runtime().block_on(async {
|
||||
let connect = async {
|
||||
#[cfg(unix)]
|
||||
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::UnixStream::connect(name).await.map_err(failed)?)) }
|
||||
#[cfg(windows)]
|
||||
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(name).map_err(failed)?)) }
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{ let _ = name; Err(HostError::Failed("local IPC unavailable".into())) }
|
||||
};
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(timeout, connect) => r.map_err(|_| HostError::Failed("IPC connection timed out".into()))?,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})?;
|
||||
let mut entries = self.entries.lock();
|
||||
check(&entries)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
(plugin.into(), id),
|
||||
Arc::new(Connection { stream: tokio::sync::Mutex::new(stream), closed: CancellationToken::new() }),
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
fn get(&self, plugin: &str, id: u64) -> Result<Arc<Connection>, HostError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.get(&(plugin.into(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound("IPC connection".into()))
|
||||
}
|
||||
pub fn write(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
bytes: Vec<u8>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let entry = self.get(plugin, id)?;
|
||||
net_runtime().block_on(async {
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(timeout, async { entry.stream.lock().await.write_all(&bytes).await.map_err(failed) }) => r.map_err(|_| HostError::Failed("IPC write timed out".into()))?,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
() = entry.closed.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn read(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
max: usize,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Vec<u8>, HostError> {
|
||||
let entry = self.get(plugin, id)?;
|
||||
net_runtime().block_on(async {
|
||||
let read = async {
|
||||
let mut bytes = vec![0; max.min(1 << 20)];
|
||||
let count = entry.stream.lock().await.read(&mut bytes).await.map_err(failed)?;
|
||||
bytes.truncate(count);
|
||||
Ok(bytes)
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
() = entry.closed.cancelled() => Err(HostError::Cancelled),
|
||||
r = read => r,
|
||||
() = tokio::time::sleep(timeout) => Ok(Vec::new()),
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn close(&self, plugin: &str, id: u64) {
|
||||
if let Some(entry) = self.entries.lock().remove(&(plugin.into(), id)) {
|
||||
entry.closed.cancel();
|
||||
}
|
||||
}
|
||||
pub fn clear(&self, plugin: &str) {
|
||||
self.entries.lock().retain(|(p, _), entry| {
|
||||
if p != plugin {
|
||||
return true;
|
||||
}
|
||||
entry.closed.cancel();
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
#[test]
|
||||
fn echo_limit_and_shutdown() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("echo.sock");
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut bytes = [0; 4];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
stream.write_all(&bytes).unwrap();
|
||||
// Accept the remaining seven connections before shutdown.
|
||||
for _ in 1..8 {
|
||||
listener.accept().unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Connections::default();
|
||||
let token = CancellationToken::new();
|
||||
let timeout = Duration::from_secs(2);
|
||||
let id = pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
|
||||
pool.write("a", id, b"echo".to_vec(), &token, timeout).unwrap();
|
||||
assert_eq!(pool.read("a", id, 4, timeout, &token).unwrap(), b"echo");
|
||||
for _ in 1..8 {
|
||||
pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
|
||||
}
|
||||
assert!(matches!(pool.connect("a", path.to_str().unwrap(), &token, timeout), Err(HostError::Invalid(_))));
|
||||
assert!(pool.read("b", id, 4, timeout, &token).is_err());
|
||||
pool.clear("a");
|
||||
assert!(pool.read("a", id, 4, timeout, &token).is_err());
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! API 1.4 language features. Results are bounded and checked against the snapshot.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{IdeResult, Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::*};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn valid_range(doc: &ProviderDocument, range: Range) -> bool {
|
||||
valid_range_in(&ide_text::Document::new(&doc.text), range)
|
||||
}
|
||||
pub(super) fn valid_range_in(text: &ide_text::Document, range: Range) -> bool {
|
||||
if range.start > range.end {
|
||||
return false;
|
||||
}
|
||||
[range.start, range.end].iter().all(|p| text.offset_to_pos(text.pos_to_offset(*p)) == *p)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub fn language_format(
|
||||
&self,
|
||||
id: &str,
|
||||
doc: &ProviderDocument,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<ide_core::TextEdit>> {
|
||||
let candidates = if let Some(r) = range {
|
||||
self.feature_documents(doc, Some(r.start))
|
||||
} else {
|
||||
vec![(doc.clone(), super::injections::Bounds::unrestricted())]
|
||||
};
|
||||
let (target, bounds) = candidates
|
||||
.into_iter()
|
||||
.find(|(d, b)| {
|
||||
range.is_none_or(|r| b.contains(r))
|
||||
&& self
|
||||
.provider_plugins(ProviderKind::Formatting, &d.language)
|
||||
.iter()
|
||||
.any(|p| p.id() == id && p.dev_root.as_ref().is_none_or(|r| d.path.starts_with(r)))
|
||||
})
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("The selected plugin formatter is unavailable"))?;
|
||||
let text = ide_text::Document::new(&target.text);
|
||||
let d = Arc::new(target);
|
||||
let edits = self.code_call(id, ide_plugin::Invocation::default(), "formatting", move |rt, _| {
|
||||
rt.language_format(&d, range, options.clone(), std::time::Duration::from_secs(5))
|
||||
})?;
|
||||
if edits.iter().any(|e| !valid_range_in(&text, e.range) || !bounds.contains(e.range)) {
|
||||
return Err(ide_core::IdeError::invalid("Plugin formatter returned an invalid range"));
|
||||
}
|
||||
let mut ranges: Vec<_> = edits.iter().map(|e| e.range).collect();
|
||||
ranges.sort();
|
||||
if ranges.windows(2).any(|p| p[0].end > p[1].start) {
|
||||
return Err(ide_core::IdeError::invalid("Plugin formatter returned overlapping edits"));
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
pub fn language_signature_help(&self, doc: &ProviderDocument, at: Pos) -> Option<SignatureHelp> {
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::SignatureHelp, &snapshot, move |rt, t| {
|
||||
rt.language_signature_help(&d, at, t)
|
||||
});
|
||||
for (_, v) in results {
|
||||
if let Some(v) = v.filter(|h| !h.signatures.is_empty()) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn language_document_highlights(&self, doc: &ProviderDocument, at: Pos) -> Vec<Highlight> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::DocumentHighlights, &snapshot, move |rt, t| {
|
||||
rt.language_document_highlights(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_document_links(&self, doc: &ProviderDocument) -> Vec<DocumentLink> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self
|
||||
.document_calls(ProviderKind::DocumentLinks, &snapshot, move |rt, t| rt.language_document_links(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_code_lenses(&self, doc: &ProviderDocument) -> Vec<(String, CodeLens)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results =
|
||||
self.document_calls(ProviderKind::CodeLenses, &snapshot, move |rt, t| rt.language_code_lenses(&d, t));
|
||||
for (p, items) in results {
|
||||
let commands: std::collections::HashSet<_> =
|
||||
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
|
||||
for i in items {
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) && commands.contains(&i.command) {
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_selection_ranges(&self, doc: &ProviderDocument, at: Pos) -> Vec<Range> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::SelectionRanges, &snapshot, move |rt, t| {
|
||||
rt.language_selection_ranges(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i) && i.start <= at && at <= i.end && bounds.contains(i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
|
||||
out.dedup();
|
||||
let mut end = None;
|
||||
out.retain(|r| {
|
||||
let ok = end.is_none_or(|prev: Range| r.start <= prev.start && prev.end <= r.end);
|
||||
if ok {
|
||||
end = Some(*r);
|
||||
}
|
||||
ok
|
||||
});
|
||||
out
|
||||
}
|
||||
pub fn language_folding(&self, doc: &ProviderDocument) -> Vec<FoldRange> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let lines = d.text.split('\n').count() as u32;
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results =
|
||||
self.document_calls(ProviderKind::Folding, &snapshot, move |rt, t| rt.language_folding(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if i.start_line < i.end_line && i.end_line < lines && bounds.lines(i.start_line, i.end_line) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_colors(&self, doc: &ProviderDocument) -> Vec<(String, ColorInfo)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::Colors, &snapshot, move |rt, t| rt.language_colors(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
if valid_range_in(&text, i.range)
|
||||
&& bounds.contains(i.range)
|
||||
&& [i.red, i.green, i.blue, i.alpha].iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c))
|
||||
{
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_color_presentations(&self, id: &str, doc: &ProviderDocument, color: ColorInfo) -> Vec<String> {
|
||||
for (d, bounds) in self.feature_documents(doc, Some(color.range.start)) {
|
||||
if !bounds.contains(color.range)
|
||||
|| !self.provider_plugins(ProviderKind::Colors, &d.language).iter().any(|p| p.id() == id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let color = color.clone();
|
||||
let result = self.document_calls_for(ProviderKind::Colors, &snapshot, Some(id), move |rt, t| {
|
||||
rt.language_color_presentations(&d, color.clone(), t)
|
||||
});
|
||||
if let Some((_, items)) = result.into_iter().find(|(p, _)| p.id() == id) {
|
||||
return items.into_iter().take(100).collect();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
pub fn language_document_symbols(&self, doc: &ProviderDocument) -> Vec<DocumentSymbol> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::DocumentSymbols, &snapshot, move |rt, t| {
|
||||
rt.language_document_symbols(&d, t)
|
||||
});
|
||||
for (_, items) in results {
|
||||
let mut remap = std::collections::HashMap::new();
|
||||
for (index, mut i) in items.into_iter().enumerate() {
|
||||
if !valid_range_in(&text, i.range)
|
||||
|| !valid_range_in(&text, i.selection_range)
|
||||
|| i.selection_range.start < i.range.start
|
||||
|| i.selection_range.end > i.range.end
|
||||
|| !bounds.contains(i.range)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
i.parent = i.parent.and_then(|p| remap.get(&(p as usize)).copied());
|
||||
remap.insert(index, out.len() as u32);
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_implementation(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
|
||||
let mut out = Vec::new();
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::Implementation, &snapshot, move |rt, t| {
|
||||
rt.language_implementation(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_type_definition(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
|
||||
let mut out = Vec::new();
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::TypeDefinition, &snapshot, move |rt, t| {
|
||||
rt.language_type_definition(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_gutter_actions(&self, doc: &ProviderDocument) -> Vec<(String, GutterAction)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let lines = d.text.split('\n').count() as u32;
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self
|
||||
.document_calls(ProviderKind::GutterActions, &snapshot, move |rt, t| rt.language_gutter_actions(&d, t));
|
||||
for (p, items) in results {
|
||||
let commands: std::collections::HashSet<_> =
|
||||
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
|
||||
for i in items {
|
||||
if i.line < lines && bounds.lines(i.line, i.line) && commands.contains(&i.command) {
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(crate) fn run_language_command(
|
||||
&self,
|
||||
plugin: &str,
|
||||
command: &str,
|
||||
argument: Option<&str>,
|
||||
invocation: ide_plugin::Invocation,
|
||||
) -> IdeResult<()> {
|
||||
if !self.ui.items(super::COMMANDS, Some(plugin)).iter().any(|c| c.key == command) {
|
||||
return Err(ide_core::IdeError::not_found("The plugin did not declare this command"));
|
||||
}
|
||||
self.run_command(plugin, command, argument, invocation).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn signature_help(h: SignatureHelp) -> ide_lsp::types::SignatureHelp {
|
||||
let signatures: Vec<_> = h
|
||||
.signatures
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|s| {
|
||||
let len = s.label.encode_utf16().count() as u32;
|
||||
let parameters = s
|
||||
.parameters
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|p| match p.label {
|
||||
ParameterLabel::Offsets((a, b)) => (a.min(len), b.max(a).min(len)),
|
||||
ParameterLabel::Text(t) => s
|
||||
.label
|
||||
.find(&t)
|
||||
.map(|a| {
|
||||
let start = s.label[..a].encode_utf16().count() as u32;
|
||||
(start, start + t.encode_utf16().count() as u32)
|
||||
})
|
||||
.unwrap_or((0, 0)),
|
||||
})
|
||||
.collect();
|
||||
ide_lsp::types::Signature {
|
||||
label: s.label,
|
||||
documentation: s.documentation.map(|value| ide_lsp::types::Markup { kind: "markdown".into(), value }),
|
||||
parameters,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let active_signature = h.active_signature.min(signatures.len().saturating_sub(1) as u32);
|
||||
let active_parameter =
|
||||
h.active_parameter
|
||||
.min(signatures.get(active_signature as usize).map(|s| s.parameters.len().saturating_sub(1)).unwrap_or(0)
|
||||
as u32);
|
||||
ide_lsp::types::SignatureHelp { signatures, active_signature, active_parameter }
|
||||
}
|
||||
|
||||
pub(crate) fn outline(items: Vec<DocumentSymbol>) -> Vec<ide_core::OutlineItem> {
|
||||
// A parent must precede its child, ruling out cycles and unbounded recursion.
|
||||
let mut depths = vec![0usize; items.len()];
|
||||
let mut parents = vec![None; items.len()];
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
if let Some(parent) = item.parent.map(|p| p as usize).filter(|p| {
|
||||
*p < idx
|
||||
&& depths[*p] < 63
|
||||
&& items[*p].range.start <= item.range.start
|
||||
&& item.range.end <= items[*p].range.end
|
||||
}) {
|
||||
parents[idx] = Some(parent);
|
||||
depths[idx] = depths[parent] + 1;
|
||||
}
|
||||
}
|
||||
let mut nodes: Vec<_> = items
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
Some(ide_core::OutlineItem {
|
||||
name: i.name,
|
||||
detail: Some(i.detail).filter(|s| !s.is_empty()),
|
||||
kind: serde_json::from_value(serde_json::Value::String(symbol_kind_name(&i.kind)))
|
||||
.unwrap_or(ide_core::SymbolKind::Unknown),
|
||||
range: i.range,
|
||||
selection_range: i.selection_range,
|
||||
children: vec![],
|
||||
visibility: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
for idx in (0..nodes.len()).rev() {
|
||||
let mut node = nodes[idx].take().expect("once");
|
||||
node.children.reverse();
|
||||
if let Some(parent) = parents[idx] {
|
||||
nodes[parent].as_mut().expect("parent precedes child").children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
roots.reverse();
|
||||
roots
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormatterChoice {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub languages: Vec<String>,
|
||||
}
|
||||
impl PluginHost {
|
||||
pub fn formatter_choices(&self) -> Vec<FormatterChoice> {
|
||||
let mut out = std::collections::BTreeMap::<String, FormatterChoice>::new();
|
||||
for (id, languages) in self.providers.formatter_declarations() {
|
||||
let Ok(p) = self.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(p.meta.lock().status, super::PluginStatus::Active | super::PluginStatus::Inactive) {
|
||||
continue;
|
||||
}
|
||||
out.entry(id.clone())
|
||||
.or_insert_with(|| FormatterChoice { id, name: p.pkg.manifest.name.clone(), languages: vec![] })
|
||||
.languages
|
||||
.extend(languages);
|
||||
}
|
||||
out.into_values()
|
||||
.map(|mut c| {
|
||||
c.languages.sort();
|
||||
c.languages.dedup();
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol_kind_name(kind: &str) -> String {
|
||||
let mut parts = kind.split('-');
|
||||
let mut out = parts.next().unwrap_or_default().to_owned();
|
||||
for part in parts {
|
||||
let mut chars = part.chars();
|
||||
if let Some(c) = chars.next() {
|
||||
out.extend(c.to_uppercase());
|
||||
out.extend(chars);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn outline_uses_parent_order_containment_and_string_kinds() {
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 10));
|
||||
let item = |name: &str, parent| DocumentSymbol {
|
||||
name: name.into(),
|
||||
detail: String::new(),
|
||||
kind: "enum-member".into(),
|
||||
range,
|
||||
selection_range: range,
|
||||
parent,
|
||||
};
|
||||
let out =
|
||||
outline(vec![item("root", None), item("child", Some(0)), item("future", Some(3)), item("other", Some(2))]);
|
||||
assert_eq!(out[0].children[0].kind, ide_core::SymbolKind::EnumMember);
|
||||
assert_eq!(out[1].name, "future");
|
||||
assert_eq!(out[1].children[0].name, "other");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Validated MCP declarations and dispatch through the ordinary plugin actor.
|
||||
use super::{PluginHost, PluginStatus};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::{Package, UiDecl, decode};
|
||||
use ide_plugin::Invocation;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(super) const POINT: &str = "mcpTools";
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Declaration {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: std::path::PathBuf,
|
||||
read_only: bool,
|
||||
destructive: bool,
|
||||
}
|
||||
fn tool_name(plugin: &str, name: &str) -> String {
|
||||
let plugin: String = plugin
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
format!("{plugin}__{name}")
|
||||
}
|
||||
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
|
||||
pkg.manifest.needs_api("contributes.mcpTools", 4)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err("mcpTools requires wasm".into());
|
||||
}
|
||||
let d: Declaration = decode(POINT, value)?;
|
||||
if d.name.is_empty()
|
||||
|| d.name.len() > 40
|
||||
|| !d.name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|
||||
|| tool_name(pkg.id(), &d.name).len() > 64
|
||||
|| d.description.trim().is_empty()
|
||||
|| d.description.len() > 4096
|
||||
{
|
||||
return Err(
|
||||
"mcpTools: name must match [a-z0-9_]{1,40}, namespaced name at most 64 bytes, description 1–4096 bytes"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let text = pkg.read_text(&d.input_schema)?;
|
||||
if text.len() > 256 << 10 {
|
||||
return Err("mcpTools: schema exceeds 256 KiB".into());
|
||||
}
|
||||
let schema: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("mcpTools: {e}"))?;
|
||||
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
|
||||
return Err("mcpTools: inputSchema must describe an object".into());
|
||||
}
|
||||
// No HTTP or filesystem resolver features: schemas must be self-contained.
|
||||
jsonschema::validator_for(&schema).map_err(|e| format!("mcpTools: invalid JSON Schema: {e}"))?;
|
||||
let mut out = UiDecl::new(
|
||||
d.name.clone(),
|
||||
d.description.clone(),
|
||||
serde_json::json!({
|
||||
"name": d.name, "description": d.description, "inputSchema": schema, "readOnly": d.read_only, "destructive": d.destructive,
|
||||
}),
|
||||
);
|
||||
out.files.push(d.input_schema);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Live declarations only, refreshed on every list/call. Ambiguous normalized names fail closed.
|
||||
pub fn mcp_tools(&self, root: &std::path::Path) -> Vec<(String, String, ide_mcp::Tool)> {
|
||||
let mut tools = vec![];
|
||||
for item in self.ui.items(POINT, None) {
|
||||
let Ok(plugin) = self.get(&item.plugin_id) else { continue };
|
||||
if !matches!(plugin.status(), PluginStatus::Active | PluginStatus::Inactive)
|
||||
|| plugin.dev_root.as_deref().is_some_and(|r| r != root)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let d = &item.decl;
|
||||
let Some(name) = d["name"].as_str() else { continue };
|
||||
tools.push((
|
||||
plugin.id().to_owned(),
|
||||
name.to_owned(),
|
||||
ide_mcp::Tool {
|
||||
name: tool_name(plugin.id(), name),
|
||||
description: format!(
|
||||
"{} (plugin: {} [{}])",
|
||||
d["description"].as_str().unwrap_or_default(),
|
||||
plugin.manifest().name,
|
||||
plugin.id()
|
||||
),
|
||||
input_schema: d["inputSchema"].clone(),
|
||||
output_schema: None,
|
||||
read_only: d["readOnly"].as_bool().unwrap_or(false),
|
||||
destructive: d["destructive"].as_bool().unwrap_or(true),
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for (_, _, tool) in &tools {
|
||||
*counts.entry(tool.name.clone()).or_insert(0) += 1;
|
||||
}
|
||||
tools.retain(|(_, _, tool)| counts[&tool.name] == 1);
|
||||
tools
|
||||
}
|
||||
pub fn call_mcp_tool(
|
||||
&self,
|
||||
plugin: &str,
|
||||
tool: &str,
|
||||
arguments: &str,
|
||||
invocation: Invocation,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
if arguments.len() > 1 << 20 {
|
||||
return Err(IdeError::invalid("MCP arguments exceed 1 MiB"));
|
||||
}
|
||||
let root =
|
||||
invocation.workspace_root.as_ref().ok_or_else(|| IdeError::invalid("MCP requires a project window"))?;
|
||||
let (_, _, declared) = self
|
||||
.mcp_tools(std::path::Path::new(root))
|
||||
.into_iter()
|
||||
.find(|(p, n, _)| p == plugin && n == tool)
|
||||
.ok_or_else(|| IdeError::not_found("plugin MCP tool is no longer available"))?;
|
||||
let args: serde_json::Value = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let validator =
|
||||
jsonschema::validator_for(&declared.input_schema).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !validator.is_valid(&args) {
|
||||
return Err(IdeError::invalid("MCP arguments do not match inputSchema"));
|
||||
}
|
||||
let (tool, arguments) = (tool.to_owned(), arguments.to_owned());
|
||||
self.code_call_cancellable(plugin, invocation, "calling the MCP tool", Some(cancel), move |rt, inv| {
|
||||
rt.mcp_call(&tool, &arguments, inv)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_names_schema_files_versions_and_schema_keywords_at_install() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = |api: &str| {
|
||||
Package::new(
|
||||
ide_extension::Manifest::parse(&format!(
|
||||
"id='test.tools'\nname='Tools'\nversion='1.0.0'\nengines.openidea='{api}'\nwasm='plugin.wasm'"
|
||||
))
|
||||
.unwrap(),
|
||||
tmp.path().into(),
|
||||
)
|
||||
};
|
||||
let decl = |name: &str, path: &str| {
|
||||
toml::from_str::<toml::Value>(&format!(
|
||||
"name='{name}'\ndescription='Tool'\ninputSchema='{path}'\nreadOnly=true\ndestructive=false"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
std::fs::write(
|
||||
tmp.path().join("args.json"),
|
||||
r##"{"type":"object","$defs":{"text":{"type":"string"}},"properties":{"text":{"$ref":"#/$defs/text"}}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(declaration(&pkg("^1.4"), decl("echo", "args.json")).unwrap().files.len(), 1);
|
||||
assert!(declaration(&pkg("^1.3"), decl("echo", "args.json")).is_err());
|
||||
for name in ["", "Bad", "bad-name", &"x".repeat(41)] {
|
||||
assert!(declaration(&pkg("^1.4"), decl(name, "args.json")).is_err());
|
||||
}
|
||||
assert!(declaration(&pkg("^1.4"), decl("echo", "../args.json")).is_err());
|
||||
for schema in [
|
||||
r#"{"type":"nonsense"}"#,
|
||||
r#"{"type":"object","required":42}"#,
|
||||
r#"{"type":"object","properties":{"x":{"$ref":"file:///private/secret"}}}"#,
|
||||
r#"{"type":"object","properties":{"x":{"$ref":"https://example.invalid/schema"}}}"#,
|
||||
] {
|
||||
std::fs::write(tmp.path().join("args.json"), schema).unwrap();
|
||||
assert!(declaration(&pkg("^1.4"), decl("echo", "args.json")).is_err(), "{schema}");
|
||||
}
|
||||
assert_eq!(tool_name("a.b-c", "echo"), "a_b_c__echo");
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,13 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
mod auth;
|
||||
pub mod commit;
|
||||
mod database;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
mod execution;
|
||||
mod files_changed;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
@@ -27,17 +30,23 @@ mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
pub(crate) mod injections;
|
||||
mod inlay_hints;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod ipc;
|
||||
pub mod language_features;
|
||||
mod market;
|
||||
mod mcp;
|
||||
pub mod navigation;
|
||||
mod points;
|
||||
mod providers;
|
||||
pub mod remote;
|
||||
pub mod remote_host;
|
||||
mod run_detectors;
|
||||
pub mod run_targets;
|
||||
mod semantic_tokens;
|
||||
mod snippets;
|
||||
mod theme;
|
||||
mod types;
|
||||
pub mod ui_points;
|
||||
@@ -410,6 +419,11 @@ struct PendingUpdate {
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Current decoration snapshots for windows opening after a plugin published them.
|
||||
pub fn decoration_sets(&self) -> std::collections::BTreeMap<String, Vec<DecorationSet>> {
|
||||
self.shared.decoration_sets()
|
||||
}
|
||||
|
||||
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
|
||||
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
@@ -653,6 +667,7 @@ impl PluginHost {
|
||||
|
||||
/// Unloads the project plugins of `root` (the project became untrusted or closed).
|
||||
pub fn restrict_project(&self, root: &Path) {
|
||||
self.shared.withdraw_run_targets(None, Some(root));
|
||||
let _guard = self.lifecycle.lock();
|
||||
let ids: Vec<_> = self
|
||||
.plugins
|
||||
@@ -1080,7 +1095,9 @@ impl PluginHost {
|
||||
ledger.set(&subject, &cap, granted);
|
||||
if granted
|
||||
&& let Capability::Process(program) = &cap
|
||||
&& let Some(bin) = self.resolve_binary(program)
|
||||
&& let Some(bin) = ledger
|
||||
.program_choice(&subject, &cap)
|
||||
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
|
||||
{
|
||||
ledger.set_binary(&subject, &cap, bin);
|
||||
}
|
||||
@@ -1097,14 +1114,74 @@ impl PluginHost {
|
||||
/// The program a `process:` grant allows, checked against the file
|
||||
/// approved for it. Errors ask the user to confirm again.
|
||||
pub fn program_for(&self, id: &str, program: &str) -> IdeResult<PathBuf> {
|
||||
self.program_for_project(id, program, None)
|
||||
}
|
||||
|
||||
/// Resolves the approved executable for the invoking project, verifying its pinned content.
|
||||
pub fn program_for_project(&self, id: &str, program: &str, root: Option<&Path>) -> IdeResult<PathBuf> {
|
||||
let plugin = self.get(id)?;
|
||||
let cap = Capability::Process(program.to_owned());
|
||||
self.ledger
|
||||
.lock()
|
||||
.check_binary(&plugin.subject(), &cap)
|
||||
.check_binary_in_project(&plugin.subject(), &Capability::Process(program.into()), root)
|
||||
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
|
||||
}
|
||||
|
||||
/// Records a user-approved executable choice; removing it revalidates the fallback or revokes the grant.
|
||||
pub fn choose_program(
|
||||
&self,
|
||||
id: &str,
|
||||
capability: &str,
|
||||
root: Option<&Path>,
|
||||
path: Option<&Path>,
|
||||
) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
|
||||
let Capability::Process(program) = &cap else {
|
||||
return Err(IdeError::invalid("expected a process capability"));
|
||||
};
|
||||
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
|
||||
return Err(IdeError::invalid("undeclared process capability"));
|
||||
}
|
||||
let binary = if let Some(path) = path {
|
||||
Some(ledger::approve_program(path).map_err(IdeError::invalid)?)
|
||||
} else if root.is_none() {
|
||||
self.resolve_binary(program)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
{
|
||||
let mut ledger = self.ledger.lock();
|
||||
let subject = plugin.subject();
|
||||
if ledger.get(&subject).is_none() {
|
||||
return Err(IdeError::invalid("confirm this plugin's permissions first"));
|
||||
}
|
||||
if let Some(root) = root {
|
||||
ledger.set_project_binary(&subject, &cap, root, binary);
|
||||
if path.is_some() {
|
||||
ledger.set(&subject, &cap, true);
|
||||
} else if ledger.check_binary(&subject, &cap).is_err() {
|
||||
ledger.set(&subject, &cap, false);
|
||||
}
|
||||
} else if let Some(binary) = binary {
|
||||
ledger.set_program_choice(&subject, &cap, path);
|
||||
ledger.set(&subject, &cap, true);
|
||||
ledger.set_binary(&subject, &cap, binary);
|
||||
} else {
|
||||
ledger.set_program_choice(&subject, &cap, None);
|
||||
ledger.set(&subject, &cap, false);
|
||||
}
|
||||
self.save_ledger(&ledger);
|
||||
}
|
||||
self.stop(&plugin);
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
|
||||
fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> {
|
||||
if install::uninstall_pending(&self.plugins_dir, id)? {
|
||||
return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string()));
|
||||
@@ -1151,6 +1228,7 @@ impl PluginHost {
|
||||
}
|
||||
self.stop(&plugin);
|
||||
let cleanup = || -> std::io::Result<()> {
|
||||
self.remove_database_sources(id).map_err(|e| std::io::Error::other(e.message))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?;
|
||||
install::check_not_symlink(&self.shared.data_dir.join(id))?;
|
||||
@@ -1342,6 +1420,17 @@ impl PluginHost {
|
||||
invocation: Invocation,
|
||||
what: &str,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
self.code_call_cancellable(id, invocation, what, None, f)
|
||||
}
|
||||
|
||||
fn code_call_cancellable<R: Send + 'static>(
|
||||
&self,
|
||||
id: &str,
|
||||
invocation: Invocation,
|
||||
what: &str,
|
||||
cancel: Option<&ide_core::CancellationToken>,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
let plugin = self.get(id)?;
|
||||
let client = invocation.client.clone();
|
||||
@@ -1383,7 +1472,14 @@ impl PluginHost {
|
||||
}
|
||||
let started = Instant::now();
|
||||
let (call, inv) = (f.clone(), invocation.clone());
|
||||
let result = actor.call(None, move |rt| call(rt, inv));
|
||||
let result = match cancel {
|
||||
Some(cancel) => actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv)),
|
||||
None => actor.call(None, move |rt| call(rt, inv)),
|
||||
};
|
||||
if matches!(result, Err(ActorError::Cancelled)) {
|
||||
self.reap(&plugin, &actor, client.as_deref());
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) {
|
||||
let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_))));
|
||||
// Cancelling or a missing grant is not the plugin failing.
|
||||
@@ -1457,7 +1553,12 @@ impl PluginHost {
|
||||
|
||||
/// Plugins' `runDetectors`, for [`ide_run::set_external_detection`].
|
||||
pub fn run_detection(&self) -> Arc<dyn ide_run::ExternalDetection> {
|
||||
Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() })
|
||||
Arc::new(run_detectors::PluginRunDetection {
|
||||
store: self.run_detectors.clone(),
|
||||
dynamic: self.shared.run_configurations.clone(),
|
||||
pins: self.pins.clone(),
|
||||
project_pins: self.shared.project_pins.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The syntax of plugin languages, for [`ide_language::set_external_syntax`].
|
||||
@@ -1513,6 +1614,10 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
/// Plugin context keys in effect for window `client`: global keys overlaid with its own.
|
||||
pub fn status_snapshot(&self, client: &str) -> Vec<PluginStatusItem> {
|
||||
self.shared.status_snapshot(client)
|
||||
}
|
||||
|
||||
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
|
||||
self.shared.context_keys(client)
|
||||
}
|
||||
@@ -1543,6 +1648,10 @@ impl PluginHost {
|
||||
|
||||
/// A window answered a plugin's `CommitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
pub fn answer_command(&self, client: &str, request: &str, answer: CommandAnswer) -> bool {
|
||||
request.parse().is_ok_and(|id| self.shared.answer_command(client, id, answer))
|
||||
}
|
||||
|
||||
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
|
||||
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
|
||||
}
|
||||
@@ -1632,6 +1741,30 @@ impl PluginHost {
|
||||
|
||||
/// Queues workbench events from window `client` for the plugins that want
|
||||
/// them; [`flush_events`](Self::flush_events) delivers them in batches.
|
||||
pub fn post_window_events(&self, client: &str, events: Vec<WorkbenchEvent>, editor: Option<PluginEditor>) {
|
||||
let mut editors = self.shared.event_editors.write();
|
||||
if let Some(editor) = editor {
|
||||
editors.insert(client.into(), editor.context());
|
||||
} else {
|
||||
editors.remove(client);
|
||||
}
|
||||
drop(editors);
|
||||
self.post_events(
|
||||
Some(client),
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
WorkbenchEvent::RunExited(_)
|
||||
| WorkbenchEvent::TerminalExited(_)
|
||||
| WorkbenchEvent::ContentTabAction(_)
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
|
||||
self.post_events_from(client, events, None);
|
||||
}
|
||||
@@ -1661,7 +1794,13 @@ impl PluginHost {
|
||||
.as_ref()
|
||||
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
|
||||
})
|
||||
.cloned()
|
||||
.filter_map(|e| match e {
|
||||
WorkbenchEvent::SettingsChanged(_) => {
|
||||
let keys = self.shared.changed_settings(&p.pkg, client);
|
||||
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
|
||||
}
|
||||
_ => Some(e.clone()),
|
||||
})
|
||||
.collect();
|
||||
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
|
||||
if wanted.is_empty() {
|
||||
@@ -1682,7 +1821,15 @@ impl PluginHost {
|
||||
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
|
||||
let mut remaining = excess;
|
||||
queue.events.retain(|event| {
|
||||
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
|
||||
if remaining > 0
|
||||
&& !matches!(
|
||||
event,
|
||||
WorkbenchEvent::FilesChanged(_)
|
||||
| WorkbenchEvent::RunExited(_)
|
||||
| WorkbenchEvent::TerminalExited(_)
|
||||
| WorkbenchEvent::ContentTabAction(_)
|
||||
)
|
||||
{
|
||||
remaining -= 1;
|
||||
false
|
||||
} else {
|
||||
@@ -1809,9 +1956,10 @@ impl PluginHost {
|
||||
let invocation = Invocation {
|
||||
workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)),
|
||||
client: client.clone(),
|
||||
editor: None,
|
||||
editor: client.as_deref().and_then(|c| self.shared.event_editors.read().get(c).cloned()),
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
command_chain: Vec::new(),
|
||||
};
|
||||
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
|
||||
let filesystem = kinds.contains(&EventKind::FilesChanged);
|
||||
@@ -1823,6 +1971,9 @@ impl PluginHost {
|
||||
if batches.running.as_ref() == Some(&kinds)
|
||||
&& !kinds.contains(&EventKind::Scheduled)
|
||||
&& !kinds.contains(&EventKind::FilesChanged)
|
||||
&& !kinds.iter().any(|k| {
|
||||
matches!(k, EventKind::RunExited | EventKind::TerminalExited | EventKind::ContentTabAction)
|
||||
})
|
||||
{
|
||||
actor.cancel_soft();
|
||||
}
|
||||
@@ -2599,8 +2750,21 @@ impl PluginHost {
|
||||
/// pick up a change on their next lookup.
|
||||
fn refresh_pins(&self, ledger: &Ledger) {
|
||||
let mut pins = HashMap::new();
|
||||
let mut project_pins = HashMap::new();
|
||||
for plugin in self.plugins.read().values() {
|
||||
let subject = plugin.subject();
|
||||
if let Some(grant) = ledger.get(&subject) {
|
||||
for (root, binaries) in &grant.project_binaries {
|
||||
for (cap, bin) in binaries {
|
||||
if grant.granted.contains(cap) {
|
||||
project_pins.insert(
|
||||
(plugin.id().to_owned(), cap.clone(), root.clone()),
|
||||
ProgramPin { path: bin.path.clone(), hash: bin.hash.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let declared =
|
||||
plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone());
|
||||
for cap in declared.filter(|c| matches!(c, Capability::Process(_))) {
|
||||
@@ -2612,6 +2776,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
*self.shared.project_pins.write() = project_pins;
|
||||
let mut current = self.pins.write();
|
||||
if *current != pins {
|
||||
*current = pins;
|
||||
@@ -2665,12 +2830,26 @@ impl PluginHost {
|
||||
|
||||
fn record_consent(&self, request: &Request<'_>, consent: &Consent) {
|
||||
let accepted: Vec<Capability> = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect();
|
||||
let choices: BTreeMap<_, _> = {
|
||||
let ledger = self.ledger.lock();
|
||||
request
|
||||
.required
|
||||
.iter()
|
||||
.chain(&accepted)
|
||||
.filter_map(|cap| {
|
||||
ledger.program_choice(&request.subject, cap).map(|p| (cap.to_string(), p.to_path_buf()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let binaries: BTreeMap<String, BinaryGrant> = request
|
||||
.required
|
||||
.iter()
|
||||
.chain(&accepted)
|
||||
.filter_map(|c| match c {
|
||||
Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)),
|
||||
Capability::Process(program) => choices
|
||||
.get(&c.to_string())
|
||||
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
|
||||
.map(|b| (c.to_string(), b)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -197,6 +197,7 @@ impl PluginHost {
|
||||
/// navigation_at. Call off the UI thread, in small cancellable batches.
|
||||
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
|
||||
let Snapshot { path, version, text, symbols, scope } = snapshot;
|
||||
let path = dunce::simplified(path);
|
||||
if !(self.project_policy.read())(root) {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
|
||||
@@ -20,15 +20,23 @@ pub struct CommandDecl {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
/// Enablement, evaluated by the client against context keys (`editorLang == rust`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<String>,
|
||||
}
|
||||
|
||||
fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
fn command(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let c: CommandDecl = decode(COMMANDS, decl)?;
|
||||
validate_item_id(&c.id, "command id")?;
|
||||
if let Some(icon) = &c.icon {
|
||||
pkg.manifest.needs_api("contributes.commands.icon", 4)?;
|
||||
if icon.is_empty() || icon.len() > 256 {
|
||||
return Err("command icon: expected 1–256 bytes".into());
|
||||
}
|
||||
}
|
||||
if c.title.trim().is_empty() {
|
||||
return Err(format!("command {:?} has an empty title", c.id));
|
||||
}
|
||||
@@ -40,7 +48,14 @@ fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
}
|
||||
let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c };
|
||||
let json = serde_json::to_value(&c).map_err(|e| e.to_string())?;
|
||||
Ok(UiDecl::new(c.id.clone(), c.title, json))
|
||||
let mut out = UiDecl::new(c.id.clone(), c.title, json);
|
||||
if let Some(icon) = c.icon.filter(|s| s.ends_with(".svg")) {
|
||||
let path = std::path::PathBuf::from(icon);
|
||||
let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?;
|
||||
out.files.push(path);
|
||||
out.assets.push((format!("commandIcon/{}", c.id), svg));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Registers every first-party extension point.
|
||||
@@ -60,6 +75,13 @@ pub fn register_builtin_points(
|
||||
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)))?;
|
||||
registry.register(Arc::new(UiPoint::new(
|
||||
"runConfigurations",
|
||||
ui.clone(),
|
||||
super::execution::configurations_declaration,
|
||||
)))?;
|
||||
registry.register(Arc::new(UiPoint::new("snippets", ui.clone(), super::snippets::declaration)))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::mcp::POINT, ui.clone(), super::mcp::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)))?;
|
||||
|
||||
@@ -42,6 +42,19 @@ pub enum ProviderKind {
|
||||
Hover,
|
||||
InlayHints,
|
||||
Diagnostics,
|
||||
Formatting,
|
||||
SignatureHelp,
|
||||
DocumentHighlights,
|
||||
DocumentLinks,
|
||||
CodeLenses,
|
||||
SelectionRanges,
|
||||
Folding,
|
||||
Colors,
|
||||
DocumentSymbols,
|
||||
Implementation,
|
||||
TypeDefinition,
|
||||
GutterActions,
|
||||
Injections,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -94,9 +107,30 @@ impl Providers {
|
||||
}
|
||||
|
||||
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
|
||||
self.matching(ProviderKind::Diagnostics, language)
|
||||
if self.any(ProviderKind::Injections) {
|
||||
let mut out = HashMap::new();
|
||||
for (seq, plugin, kinds, _) in self.items.read().iter() {
|
||||
if kinds.contains(&ProviderKind::Diagnostics) {
|
||||
out.entry(plugin.clone()).or_insert(*seq);
|
||||
}
|
||||
}
|
||||
out
|
||||
} else {
|
||||
self.matching(ProviderKind::Diagnostics, language)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn injection_generations(&self, language: &str) -> HashMap<String, u64> {
|
||||
self.matching(ProviderKind::Injections, language)
|
||||
}
|
||||
pub(super) fn formatter_declarations(&self) -> Vec<(String, Vec<String>)> {
|
||||
self.items
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, _, k, _)| k.contains(&ProviderKind::Formatting))
|
||||
.map(|(_, p, _, l)| (p.clone(), l.clone()))
|
||||
.collect()
|
||||
}
|
||||
/// Whether any plugin provides `kind` (for any language).
|
||||
pub fn any(&self, kind: ProviderKind) -> bool {
|
||||
self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind))
|
||||
@@ -142,6 +176,26 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.contains(&ProviderKind::InlayHints) {
|
||||
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
|
||||
}
|
||||
if d.kinds.iter().any(|k| {
|
||||
matches!(
|
||||
k,
|
||||
ProviderKind::Formatting
|
||||
| ProviderKind::SignatureHelp
|
||||
| ProviderKind::DocumentHighlights
|
||||
| ProviderKind::DocumentLinks
|
||||
| ProviderKind::CodeLenses
|
||||
| ProviderKind::SelectionRanges
|
||||
| ProviderKind::Folding
|
||||
| ProviderKind::Colors
|
||||
| ProviderKind::DocumentSymbols
|
||||
| ProviderKind::Implementation
|
||||
| ProviderKind::TypeDefinition
|
||||
| ProviderKind::GutterActions
|
||||
| ProviderKind::Injections
|
||||
)
|
||||
}) {
|
||||
pkg.manifest.needs_api("contributes.providers: language features and injections", 4)?;
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
@@ -211,7 +265,7 @@ pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
|
||||
/// The plugin host whose providers take part, when any declares `kind`.
|
||||
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
|
||||
let host = editor_plugin_host()?;
|
||||
host.providers.any(kind).then_some(host)
|
||||
(host.providers.any(kind) || host.providers.any(ProviderKind::Injections)).then_some(host)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
|
||||
@@ -236,15 +290,46 @@ impl PluginHost {
|
||||
/// declares `kind` for `language`, in plugin order, leaving out those that
|
||||
/// failed or were late. Each plugin runs on its own thread, so the calls
|
||||
/// run at the same time: the wait is the slowest plugin's, not the sum.
|
||||
fn edit_calls<R: Send + 'static>(
|
||||
pub(super) fn edit_calls<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
language: &str,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
self.edit_calls_plugins(self.provider_plugins(kind, language), f)
|
||||
}
|
||||
|
||||
pub(super) fn document_calls<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
doc: &ProviderDocument,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
self.document_calls_for(kind, doc, None, f)
|
||||
}
|
||||
pub(super) fn document_calls_for<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
doc: &ProviderDocument,
|
||||
owner: Option<&str>,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
let plugins = self
|
||||
.provider_plugins(kind, &doc.language)
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)) && owner.is_none_or(|id| p.id() == id)
|
||||
})
|
||||
.collect();
|
||||
self.edit_calls_plugins(plugins, f)
|
||||
}
|
||||
fn edit_calls_plugins<R: Send + 'static>(
|
||||
&self,
|
||||
plugins: Vec<Arc<LoadedPlugin>>,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard);
|
||||
let queued: Vec<_> = self
|
||||
.provider_plugins(kind, language)
|
||||
let queued: Vec<_> = plugins
|
||||
.into_iter()
|
||||
.filter_map(|plugin| {
|
||||
let (actor, activated) = self.ensure_active(&plugin, None).ok()?;
|
||||
@@ -303,13 +388,24 @@ impl PluginHost {
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> Vec<(String, CompletionItem)> {
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
|
||||
.collect()
|
||||
self.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.flat_map(|(doc, bounds)| {
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
|
||||
.filter_map(move |(p, mut i)| {
|
||||
if i.replace.is_some_and(|r| !bounds.contains(r)) {
|
||||
return None;
|
||||
}
|
||||
i.additional_edits.retain(|e| bounds.contains(e.range));
|
||||
Some((p, i))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_code_actions(
|
||||
@@ -391,10 +487,20 @@ impl PluginHost {
|
||||
project: Option<&std::path::Path>,
|
||||
language: &str,
|
||||
) -> Vec<Arc<LoadedPlugin>> {
|
||||
self.provider_plugins(ProviderKind::Diagnostics, language)
|
||||
.into_iter()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.collect()
|
||||
let plugins = if self.providers.any(ProviderKind::Injections) {
|
||||
self.providers
|
||||
.diagnostic_generations(language)
|
||||
.keys()
|
||||
.filter_map(|id| self.get(id).ok())
|
||||
.filter(|p| {
|
||||
let m = p.meta.lock();
|
||||
matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty()
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
self.provider_plugins(ProviderKind::Diagnostics, language)
|
||||
};
|
||||
plugins.into_iter().filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project).collect()
|
||||
}
|
||||
|
||||
pub(super) fn provide_project_diagnostics(
|
||||
@@ -402,11 +508,35 @@ impl PluginHost {
|
||||
project: Option<&std::path::Path>,
|
||||
doc: &ProviderDocument,
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
|
||||
let mut out = std::collections::BTreeMap::<String, Vec<ide_core::Diagnostic>>::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
for (id, items) in self.provide_document_diagnostics(project, &d, cancel) {
|
||||
out.entry(id).or_default().extend(items.into_iter().filter(|i| bounds.contains(i.range)));
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|(id, mut items)| {
|
||||
items.truncate(5000);
|
||||
(id, items)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provide_document_diagnostics(
|
||||
&self,
|
||||
project: Option<&std::path::Path>,
|
||||
doc: &ProviderDocument,
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
|
||||
std::thread::scope(|scope| {
|
||||
let calls: Vec<_> = self
|
||||
.diagnostic_plugins(project, &doc.language)
|
||||
.provider_plugins(ProviderKind::Diagnostics, &doc.language)
|
||||
.into_iter()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.map(|p| {
|
||||
scope.spawn(move || {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -432,10 +562,15 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
self.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.filter_map(|(_, hover)| hover)
|
||||
.flat_map(|(doc, bounds)| {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.document_calls(ProviderKind::Hover, &doc, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
.into_iter()
|
||||
.filter_map(|(_, h)| h)
|
||||
.filter(move |h| h.range.is_none_or(|r| bounds.contains(r)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
//! keychain and are asked for with a `plugin_secret` request
|
||||
//! ([`SecretRequest`]) when a request header needs one (rule 4).
|
||||
|
||||
use super::{
|
||||
Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore,
|
||||
};
|
||||
use super::{Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, SecretStore};
|
||||
use crate::rpc::Dispatcher;
|
||||
use base64::Engine as _;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -45,6 +43,32 @@ pub const SYNC_PACKAGES: &str = "plugin_sync_packages";
|
||||
pub const EVENT_NOTIFICATION: &str = "plugin";
|
||||
/// Server → client request ([`SecretRequest`] → `Option<String>`).
|
||||
pub const SECRET_REQUEST: &str = "plugin_secret";
|
||||
/// Mutations of declared secrets, kept on the desktop.
|
||||
pub const SECRET_WRITE: &str = "plugin_secret_write";
|
||||
/// OAuth listeners bound on the desktop, never the project server.
|
||||
pub const AUTH_REQUEST: &str = "plugin_auth";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SecretWrite {
|
||||
pub plugin: String,
|
||||
pub name: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
/// A remote plugin's operation on a desktop OAuth listener.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
pub plugin: String,
|
||||
pub operation: AuthOperation,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub enum AuthOperation {
|
||||
Begin { path: String },
|
||||
Wait { id: u64, timeout_ms: u32 },
|
||||
Close { id: u64 },
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// Under the server's data directory.
|
||||
pub const PLUGINS_DIR: &str = "remote-plugins";
|
||||
const INCOMING_DIR: &str = "remote-plugins.incoming";
|
||||
@@ -61,6 +85,13 @@ pub struct RemotePackage {
|
||||
pub digest: String,
|
||||
/// Capabilities the user granted on the desktop.
|
||||
pub granted: Vec<String>,
|
||||
/// Disabled packages remain installed so their persistent project data survives.
|
||||
#[serde(default = "enabled_by_default")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn enabled_by_default() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// One file of a package (`path` relative to the package, `data` base64).
|
||||
@@ -107,8 +138,18 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
|
||||
pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
PluginEvent::Notification { .. }
|
||||
PluginEvent::RunTargetsChanged
|
||||
| PluginEvent::WindowRequest { .. }
|
||||
| PluginEvent::ContentTabReply { .. }
|
||||
| PluginEvent::DecorationsChanged { .. }
|
||||
| PluginEvent::EditorOpen { .. }
|
||||
| PluginEvent::EditorRanges { .. }
|
||||
| PluginEvent::Notification { .. }
|
||||
| PluginEvent::StatusItemChanged { .. }
|
||||
| PluginEvent::WindowStatusItemChanged { .. }
|
||||
| PluginEvent::ExecuteCommand { .. }
|
||||
| PluginEvent::OutputChanged { .. }
|
||||
| PluginEvent::RevealOutput { .. }
|
||||
| PluginEvent::ContextChanged { .. }
|
||||
| PluginEvent::TaskStarted { .. }
|
||||
| PluginEvent::TaskProgress { .. }
|
||||
@@ -144,12 +185,16 @@ impl PluginHost {
|
||||
plugins
|
||||
.iter()
|
||||
.filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some())
|
||||
.filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive))
|
||||
.filter_map(|p| {
|
||||
let files = self.package_files(p).ok()?;
|
||||
let digest = package_digest(p.dir(), &files).ok()?;
|
||||
let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect();
|
||||
Some(RemotePackage { id: p.id().to_owned(), digest, granted })
|
||||
Some(RemotePackage {
|
||||
id: p.id().to_owned(),
|
||||
digest,
|
||||
granted,
|
||||
enabled: matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -218,8 +263,19 @@ impl SecretStore for ClientSecrets {
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
|
||||
Err("secrets are stored on the desktop".into())
|
||||
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
|
||||
let peer = self.peer.get().ok_or("desktop disconnected")?.clone();
|
||||
let request = SecretWrite { plugin: plugin.into(), name: name.into(), value: value.map(str::to_owned) };
|
||||
let ask = async {
|
||||
tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Result<(), String>>(SECRET_WRITE, request, None))
|
||||
.await
|
||||
};
|
||||
let answer = if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::block_in_place(|| self.runtime.block_on(ask))
|
||||
} else {
|
||||
self.runtime.block_on(ask)
|
||||
};
|
||||
answer.map_err(|_| "desktop timed out".to_owned())?.map_err(|e| e.to_string())?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +292,10 @@ impl RemotePlugins {
|
||||
}
|
||||
};
|
||||
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
|
||||
projects.set_plugin_host(&host);
|
||||
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
|
||||
host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() }));
|
||||
host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() }));
|
||||
*host.shared.loopbacks.write() = Arc::new(ClientLoopbacks { peer, runtime: projects.runtime.clone() });
|
||||
let trust = Arc::downgrade(projects);
|
||||
host.set_project_policy(Arc::new(move |root| {
|
||||
trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false))
|
||||
@@ -285,6 +343,14 @@ impl RemotePlugins {
|
||||
report.failed.push(format!("{}: invalid plugin id", pkg.id));
|
||||
continue;
|
||||
}
|
||||
if !pkg.enabled {
|
||||
if self.host.info_of(&pkg.id).is_ok()
|
||||
&& let Err(e) = self.host.set_enabled(&pkg.id, false)
|
||||
{
|
||||
report.failed.push(format!("{}: {e}", pkg.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The desktop asked the user, change of signer included.
|
||||
let consent = Consent { optional: pkg.granted.clone(), signer_change: true };
|
||||
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
|
||||
@@ -320,6 +386,9 @@ impl RemotePlugins {
|
||||
if have != want || info.status == super::PluginStatus::NeedsConsent {
|
||||
self.host.grant(&pkg.id, consent)?;
|
||||
}
|
||||
if info.status == super::PluginStatus::Disabled {
|
||||
self.host.set_enabled(&pkg.id, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -380,6 +449,7 @@ impl RemotePlugins {
|
||||
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
|
||||
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
|
||||
let invocation = Invocation {
|
||||
command_chain: c.arg::<Option<Vec<String>>>("commandChain")?.unwrap_or_default(),
|
||||
client: Some(c.window.clone()),
|
||||
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
|
||||
editor: editor.map(PluginEditor::context),
|
||||
@@ -398,10 +468,69 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_select_tests", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let config = c.arg::<String>("config")?;
|
||||
let tests = c.arg("tests")?;
|
||||
blocking(move || host.select_tests(&c.window, &config, tests)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_parse_test_results", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let config = c.arg::<String>("config")?;
|
||||
let chunk = c.arg("chunk")?;
|
||||
blocking(move || host.parse_test_results(&c.window, &config, chunk)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_terminal_session_close", move |c| {
|
||||
let h = h.clone();
|
||||
async move { alive(&h)?.close_terminal_session(&c.window, &c.arg::<String>("operation")?) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_terminal_session_spawn", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let operation: String = c.arg("operation")?;
|
||||
let cols = c.arg("cols")?;
|
||||
let rows = c.arg("rows")?;
|
||||
let output = c.channel("onOutput")?;
|
||||
blocking(move || plugins.spawn_terminal_session(&c.host, &c.window, &operation, cols, rows, output))
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_prepare_debug_run", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let config = c.arg("config")?;
|
||||
blocking(move || plugins.prepare_debug_run(&c.window, config)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_finish_operation", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
Ok(alive(&h)?.finish_operation(&c.window, &c.arg::<String>("id")?, c.arg("status")?, c.arg("stopped")?))
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_content_tab_action", move |c| {
|
||||
let h = h.clone();
|
||||
async move { alive(&h)?.content_tab_action(&c.window, &c.arg::<String>("pluginId")?, c.arg("action")?) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_post_events", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
alive(&h)?.post_events(Some(&c.window), c.arg("events")?);
|
||||
alive(&h)?.post_window_events(&c.window, c.arg("events")?, c.arg("editor")?);
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
@@ -411,15 +540,9 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.event_kinds().into_iter().collect::<Vec<_>>()) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_status_items", move |_| {
|
||||
d.add("plugin_status_items", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
Ok(alive(&h)?
|
||||
.status_items()
|
||||
.into_iter()
|
||||
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
async move { Ok(alive(&h)?.status_snapshot(&c.window)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_context_keys", move |c| {
|
||||
@@ -437,6 +560,43 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_choose_program", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let id: String = c.arg("pluginId")?;
|
||||
let cap: String = c.arg("capability")?;
|
||||
let scope: SettingsScope = c.arg("scope")?;
|
||||
let path: Option<PathBuf> = c.arg("path")?;
|
||||
let root = match scope {
|
||||
SettingsScope::Global => None,
|
||||
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
|
||||
};
|
||||
blocking(move || host.choose_program(&id, &cap, root.as_deref(), path.as_deref())).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_program", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let id: String = c.arg("pluginId")?;
|
||||
let program: String = c.arg("program")?;
|
||||
let scope: SettingsScope = c.arg("scope")?;
|
||||
let root = match scope {
|
||||
SettingsScope::Global => None,
|
||||
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
|
||||
};
|
||||
blocking(move || host.program_for_project(&id, &program, root.as_deref()).map(ide_core::IpcPath::from))
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_command", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_command(&c.window, &c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_commit", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
@@ -456,6 +616,27 @@ impl RemotePlugins {
|
||||
.await
|
||||
}
|
||||
});
|
||||
d.add("mcp_set_open_editors", |c| async move {
|
||||
let paths: Vec<ide_core::IpcPath> = c.arg("paths")?;
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.set_open_editors(&c.window, paths.into_iter().map(PathBuf::from).collect());
|
||||
Ok(())
|
||||
});
|
||||
d.add("mcp_set_focused_editor", |c| async move {
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?);
|
||||
Ok(())
|
||||
});
|
||||
d.add("mcp_report_problems", |c| async move {
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.report_problems(&c.window, c.arg("reset")?, c.arg("files")?);
|
||||
Ok(())
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_decoration_sets", move |_c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.decoration_sets()) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
@@ -577,6 +758,21 @@ impl RemotePlugins {
|
||||
let h = h.clone();
|
||||
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_run_targets", move |c| {
|
||||
let h = h.clone();
|
||||
async move { super::run_targets::list(&c.host, alive(&h)?.as_ref(), &c.window) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_run_target", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let id: String = c.arg("id")?;
|
||||
let use_target = c.arg("useTarget")?;
|
||||
blocking(move || super::run_targets::answer(&c.host, &plugins, &c.window, &id, use_target)).await
|
||||
}
|
||||
});
|
||||
d.add("plugin_line_markers", |c| async move {
|
||||
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
@@ -621,6 +817,123 @@ fn safe_relative(path: &str) -> IdeResult<PathBuf> {
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
|
||||
struct ClientLoopbacks {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
impl ClientLoopbacks {
|
||||
fn request(
|
||||
&self,
|
||||
plugin: &str,
|
||||
operation: AuthOperation,
|
||||
cancel: Option<ide_core::CancellationToken>,
|
||||
) -> Result<serde_json::Value, ide_plugin::HostError> {
|
||||
let peer = self.peer.get().ok_or(ide_plugin::HostError::Cancelled)?.clone();
|
||||
let timeout = match &operation {
|
||||
AuthOperation::Wait { timeout_ms, .. } => Duration::from_millis(u64::from(*timeout_ms)) + SECRET_TIMEOUT,
|
||||
_ => SECRET_TIMEOUT,
|
||||
};
|
||||
let request = AuthRequest { plugin: plugin.into(), operation };
|
||||
let ask = async {
|
||||
tokio::time::timeout(
|
||||
timeout,
|
||||
peer.request::<_, Result<serde_json::Value, ide_plugin::HostError>>(AUTH_REQUEST, request, cancel),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ide_plugin::HostError::Failed("desktop OAuth request timed out".into()))?
|
||||
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))?
|
||||
};
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::block_in_place(|| self.runtime.block_on(ask))
|
||||
} else {
|
||||
self.runtime.block_on(ask)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl super::auth::Service for ClientLoopbacks {
|
||||
fn begin(&self, plugin: &str, _window: &str, path: &str) -> Result<(u64, String), ide_plugin::HostError> {
|
||||
serde_json::from_value(self.request(plugin, AuthOperation::Begin { path: path.into() }, None)?)
|
||||
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> Result<Option<String>, ide_plugin::HostError> {
|
||||
let result = self.request(
|
||||
plugin,
|
||||
AuthOperation::Wait { id, timeout_ms: timeout.as_millis().min(u128::from(u32::MAX)) as u32 },
|
||||
Some(cancel.clone()),
|
||||
);
|
||||
if cancel.is_cancelled() {
|
||||
self.close(plugin, id);
|
||||
return Err(ide_plugin::HostError::Cancelled);
|
||||
}
|
||||
serde_json::from_value(result?).map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
fn close(&self, plugin: &str, id: u64) {
|
||||
let _ = self.request(plugin, AuthOperation::Close { id }, None);
|
||||
}
|
||||
fn clear(&self, plugin: &str) {
|
||||
let _ = self.request(plugin, AuthOperation::Clear, None);
|
||||
}
|
||||
fn forget_window(&self, _: &str) {}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// A remote instance may mutate only declared secrets with a current grant.
|
||||
pub fn remote_secret_write(&self, request: SecretWrite) -> Result<(), String> {
|
||||
let plugin = self.get(&request.plugin).map_err(|e| e.message)?;
|
||||
if !plugin.meta.lock().granted.iter().any(|g| g.covers(&ide_extension::Capability::Secrets)) {
|
||||
return Err("secrets capability not granted".into());
|
||||
}
|
||||
if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == request.name) {
|
||||
return Err("undeclared secret".into());
|
||||
}
|
||||
if request.value.as_ref().is_some_and(|v| v.len() > 64 << 10) {
|
||||
return Err("secret value too long".into());
|
||||
}
|
||||
self.shared.secrets.read().set(plugin.id(), &request.name, request.value.as_deref())
|
||||
}
|
||||
/// Serves a connected remote instance using this connection's actual window.
|
||||
pub fn remote_auth(&self, window: &str, request: AuthRequest) -> Result<serde_json::Value, ide_plugin::HostError> {
|
||||
if matches!(&request.operation, AuthOperation::Begin { .. } | AuthOperation::Wait { .. }) {
|
||||
self.get(&request.plugin).map_err(|e| ide_plugin::HostError::NotFound(e.message))?;
|
||||
}
|
||||
let owner = format!("remote:{window}:{}", request.plugin);
|
||||
let service = self.shared.loopbacks.read().clone();
|
||||
let result = match request.operation {
|
||||
AuthOperation::Begin { path } => {
|
||||
if self.shared.client_closed(window) {
|
||||
return Err(ide_plugin::HostError::Cancelled);
|
||||
}
|
||||
serde_json::to_value(service.begin(&owner, window, &path)?)
|
||||
}
|
||||
AuthOperation::Wait { id, timeout_ms } => serde_json::to_value(service.wait(
|
||||
&owner,
|
||||
id,
|
||||
Duration::from_millis(u64::from(timeout_ms)),
|
||||
&ide_core::CancellationToken::new(),
|
||||
)?),
|
||||
AuthOperation::Close { id } => {
|
||||
service.close(&owner, id);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
AuthOperation::Clear => {
|
||||
service.clear(&owner);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
};
|
||||
result.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
/// A remote transport closed; abandon its browser listeners.
|
||||
pub fn forget_remote_loopbacks(&self, window: &str) {
|
||||
self.shared.loopbacks.read().forget_window(window);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct Detector {
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub test: bool,
|
||||
/// Optional output parser, including the API 1.4 plugin export.
|
||||
#[serde(default)]
|
||||
pub framework: Option<String>,
|
||||
}
|
||||
|
||||
/// Detectors of enabled plugins.
|
||||
@@ -85,6 +88,12 @@ impl ExtensionPoint for RunDetectorsPoint {
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let d: Detector = decode(POINT_ID, decl)?;
|
||||
if d.framework.is_some() {
|
||||
pkg.manifest.needs_api("runDetectors.framework", 4)?;
|
||||
}
|
||||
if d.framework.as_ref().is_some_and(|v| v.is_empty() || v.len() > 64) {
|
||||
return Err("invalid test framework".into());
|
||||
}
|
||||
validate_item_id(&d.id, "run detector id")?;
|
||||
if d.name.trim().is_empty() || d.name.len() > 128 {
|
||||
return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id));
|
||||
@@ -110,48 +119,106 @@ impl ExtensionPoint for RunDetectorsPoint {
|
||||
}
|
||||
|
||||
type Pins = Arc<RwLock<HashMap<(String, String), ProgramPin>>>;
|
||||
pub(super) type DynamicConfigurations =
|
||||
Arc<RwLock<std::collections::BTreeMap<(String, PathBuf), Vec<ide_plugin::PluginRunConfiguration>>>>;
|
||||
|
||||
/// [`ExternalDetection`] over the detectors and the approved programs.
|
||||
pub struct PluginRunDetection {
|
||||
pub(crate) store: Arc<RunDetectors>,
|
||||
pub(crate) pins: Pins,
|
||||
pub(crate) dynamic: DynamicConfigurations,
|
||||
pub(crate) project_pins: super::api::ProjectPins,
|
||||
}
|
||||
|
||||
fn config_id(plugin: &str, detector: &str) -> String {
|
||||
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
|
||||
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
|
||||
}
|
||||
|
||||
impl PluginRunDetection {
|
||||
fn pin(&self, plugin: &str, d: &Detector) -> Option<ProgramPin> {
|
||||
fn pin(&self, plugin: &str, d: &Detector, root: Option<&Path>) -> Option<ProgramPin> {
|
||||
let cap = Capability::Process(d.command.trim().to_owned());
|
||||
self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
root.and_then(|root| self.project_pins.read().get(&(plugin.into(), cap.to_string(), root.into())).cloned())
|
||||
.or_else(|| self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned())
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalDetection for PluginRunDetection {
|
||||
fn detect(&self, root: &Path) -> Vec<RunConfig> {
|
||||
let items = self.store.items.read().clone();
|
||||
items
|
||||
let mut configs: Vec<_> = items
|
||||
.into_iter()
|
||||
.filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists()))
|
||||
.filter(|(_, plugin, d)| {
|
||||
self.pin(plugin, d, Some(root)).is_some() && d.files.iter().any(|f| root.join(f).exists())
|
||||
})
|
||||
.map(|(_, plugin, d)| RunConfig {
|
||||
id: config_id(&plugin, &d.id),
|
||||
name: d.name.clone(),
|
||||
kind: "plugin".into(),
|
||||
command: d.command.trim().to_owned(),
|
||||
args: d.args.clone(),
|
||||
cwd: None,
|
||||
cwd: Some(root.to_path_buf().into()),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: d.test,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
extras: ide_run::RunExtras {
|
||||
params: d.framework.clone().map(|f| [("framework".into(), f)].into()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
for ((plugin, project), entries) in self.dynamic.read().iter().filter(|((_, project), _)| project == root) {
|
||||
for c in entries {
|
||||
let cap = Capability::Process(c.program.clone());
|
||||
if !self.project_pins.read().contains_key(&(plugin.clone(), cap.to_string(), project.clone()))
|
||||
&& !self.pins.read().contains_key(&(plugin.clone(), cap.to_string()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut config = RunConfig {
|
||||
id: String::new(),
|
||||
name: c.name.clone(),
|
||||
kind: "plugin".into(),
|
||||
command: c.program.clone(),
|
||||
args: c.args.clone(),
|
||||
cwd: Some(root.to_path_buf().into()),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: c.test,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
config.id = config_id(plugin, &c.id);
|
||||
config.cwd = Some(c.cwd.as_ref().map(|p| root.join(p)).unwrap_or_else(|| root.to_path_buf()).into());
|
||||
if let Some(framework) = &c.framework {
|
||||
config.extras.params.insert("framework".into(), framework.clone());
|
||||
}
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
|
||||
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String> {
|
||||
let dynamic = self.dynamic.read();
|
||||
if let Some(((plugin, root), c)) =
|
||||
dynamic.iter().flat_map(|(k, entries)| entries.iter().map(move |c| (k, c))).find(|((plugin, root), c)| {
|
||||
config_id(plugin, &c.id) == config.id && config.cwd.as_ref().is_some_and(|cwd| cwd.starts_with(root))
|
||||
})
|
||||
{
|
||||
let cap = Capability::Process(c.program.clone()).to_string();
|
||||
let pin = self
|
||||
.project_pins
|
||||
.read()
|
||||
.get(&(plugin.clone(), cap.clone(), root.clone()))
|
||||
.cloned()
|
||||
.or_else(|| self.pins.read().get(&(plugin.clone(), cap)).cloned())
|
||||
.ok_or_else(|| "run program is no longer granted".to_owned())?;
|
||||
return pin.verify().map(Path::to_path_buf);
|
||||
}
|
||||
drop(dynamic);
|
||||
let items = self.store.items.read();
|
||||
let (plugin, d) = items
|
||||
.iter()
|
||||
@@ -159,7 +226,7 @@ impl ExternalDetection for PluginRunDetection {
|
||||
.map(|(_, p, d)| (p.clone(), d.clone()))
|
||||
.ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?;
|
||||
drop(items);
|
||||
let pin = self.pin(&plugin, &d).ok_or_else(|| {
|
||||
let pin = self.pin(&plugin, &d, config.cwd.as_ref().map(|p| p.as_ref())).ok_or_else(|| {
|
||||
format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command)
|
||||
})?;
|
||||
pin.verify().map(Path::to_path_buf)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Project-owned registrations, retained across idle instance reclamation.
|
||||
use super::api::{PluginApi, Shared};
|
||||
use super::{PluginEvent, PluginHost};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_plugin::{HostError, PluginRunTarget};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisteredRunTarget {
|
||||
/// Opaque selector id, namespaced by the host.
|
||||
pub id: String,
|
||||
pub plugin_id: String,
|
||||
pub plugin_name: String,
|
||||
pub target: PluginRunTarget,
|
||||
pub prompt: bool,
|
||||
}
|
||||
|
||||
pub(super) type Targets = std::collections::BTreeMap<(PathBuf, String), RegisteredRunTarget>;
|
||||
fn selector(plugin: &str, id: &str) -> String {
|
||||
format!("plugin-target:{}:{plugin}{id}", plugin.len())
|
||||
}
|
||||
impl Shared {
|
||||
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
|
||||
let key = format!("{}\0{id}", root.display());
|
||||
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
|
||||
}
|
||||
pub(super) fn withdraw_run_targets(&self, plugin: Option<&str>, root: Option<&Path>) {
|
||||
let mut targets = self.run_targets.lock();
|
||||
let before = targets.len();
|
||||
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
|
||||
if before != targets.len() {
|
||||
drop(targets);
|
||||
self.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PluginApi {
|
||||
pub(super) fn register_target(&self, root: &Path, target: PluginRunTarget) -> Result<(), HostError> {
|
||||
ide_remote::plugin_target::validate(&target).map_err(|e| HostError::Invalid(e.to_string()))?;
|
||||
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
|
||||
if !workspace.projects().iter().any(|r| r == root) {
|
||||
return Err(HostError::NotFound("project is not open".into()));
|
||||
}
|
||||
if !workspace.trusted(root) {
|
||||
return Err(HostError::Denied("project is not trusted".into()));
|
||||
}
|
||||
self.approved_program_in_project(&target.program, Some(root))?;
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let id = selector(self.pkg.id(), &target.id);
|
||||
let mut targets = self.shared.run_targets.lock();
|
||||
if !targets.contains_key(&(root.into(), id.clone()))
|
||||
&& targets.iter().filter(|((r, _), t)| r == root && t.plugin_id == self.pkg.id()).count() >= 32
|
||||
{
|
||||
return Err(HostError::Invalid("at most 32 targets per plugin and project".into()));
|
||||
}
|
||||
let prompt = !self.shared.target_answer_file(root, &id).exists();
|
||||
targets.insert(
|
||||
(root.into(), id.clone()),
|
||||
RegisteredRunTarget {
|
||||
id,
|
||||
plugin_id: self.pkg.id().into(),
|
||||
plugin_name: self.pkg.manifest.name.clone(),
|
||||
target,
|
||||
prompt,
|
||||
},
|
||||
);
|
||||
drop(targets);
|
||||
drop(blocked);
|
||||
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
|
||||
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
|
||||
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PluginHost {
|
||||
/// Drops registrations when their project closes or loses trust.
|
||||
pub fn withdraw_project_run_targets(&self, root: &Path) {
|
||||
self.shared.withdraw_run_targets(None, Some(root));
|
||||
}
|
||||
pub fn run_targets(&self, root: &Path) -> Vec<RegisteredRunTarget> {
|
||||
self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect()
|
||||
}
|
||||
pub fn prepare_run_target(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
let target = self
|
||||
.shared
|
||||
.run_targets
|
||||
.lock()
|
||||
.get(&(root.into(), id.into()))
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
|
||||
ide_remote::plugin_target::prepare(root, &target.target, &program, config)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(host: &crate::Host, plugins: &PluginHost, window: &str) -> IdeResult<Vec<RegisteredRunTarget>> {
|
||||
Ok(plugins.run_targets(&host.project(window)?.root))
|
||||
}
|
||||
|
||||
/// The first answer wins across the project's windows. Only Use changes settings.
|
||||
pub fn answer(
|
||||
host: &crate::Host,
|
||||
plugins: &PluginHost,
|
||||
window: &str,
|
||||
id: &str,
|
||||
use_target: bool,
|
||||
) -> IdeResult<ide_core::settings::Settings> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let mut targets = plugins.shared.run_targets.lock();
|
||||
let target = targets
|
||||
.get_mut(&(project.root.clone(), id.into()))
|
||||
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let file = plugins.shared.target_answer_file(&project.root, id);
|
||||
if file.exists() {
|
||||
target.prompt = false;
|
||||
return Ok(project.settings.effective());
|
||||
}
|
||||
if use_target {
|
||||
plugins.program_for_project(&target.plugin_id, &target.target.program, Some(&project.root))?;
|
||||
let scope = ide_workspace::SettingsScope::Project;
|
||||
for key in ["run.defaultTarget", "languages.php.runTarget", "languages.node.runTarget"] {
|
||||
project.settings.set(scope, key, serde_json::json!(id))?;
|
||||
for window in host.windows_for_root(&project.root) {
|
||||
if let Ok(open) = host.project(&window) {
|
||||
open.settings.reload();
|
||||
open.sink.emit(ide_core::IdeEvent::SettingsChanged { key: key.into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(file.parent().unwrap())?;
|
||||
std::fs::write(file, if use_target { "use" } else { "not-now" })?;
|
||||
target.prompt = false;
|
||||
drop(targets);
|
||||
plugins.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
Ok(project.settings.effective())
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Named JSON snippets, retained as read-only UI declarations.
|
||||
use ide_extension::{Package, UiDecl, decode};
|
||||
use serde::Deserialize;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
language: String,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Lines {
|
||||
One(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
impl Lines {
|
||||
fn values(self) -> Vec<String> {
|
||||
match self {
|
||||
Self::One(s) => vec![s],
|
||||
Self::Many(v) => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Snippet {
|
||||
prefix: Lines,
|
||||
body: Lines,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn declaration(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
pkg.manifest.needs_api("contributes.snippets", 4)?;
|
||||
let d: Decl = decode("snippets", decl)?;
|
||||
if d.language.is_empty()
|
||||
|| d.language.len() > 64
|
||||
|| !d.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
|
||||
{
|
||||
return Err("snippets: invalid language id".into());
|
||||
}
|
||||
let text = pkg.read_text(&d.path)?;
|
||||
if text.len() > 1024 * 1024 {
|
||||
return Err("snippets: file exceeds 1 MiB".into());
|
||||
}
|
||||
let snippets: std::collections::BTreeMap<String, Snippet> =
|
||||
serde_json::from_str(&text).map_err(|e| format!("snippets: {e}"))?;
|
||||
if snippets.len() > 1000 {
|
||||
return Err("snippets: at most 1000 entries per file".into());
|
||||
}
|
||||
let mut entries = vec![];
|
||||
for (name, s) in snippets {
|
||||
let prefixes = s.prefix.values();
|
||||
let body = s.body.values().join("\n");
|
||||
if name.is_empty()
|
||||
|| name.len() > 256
|
||||
|| prefixes.is_empty()
|
||||
|| prefixes.len() > 32
|
||||
|| prefixes.iter().any(|p| p.is_empty() || p.len() > 256)
|
||||
|| body.len() > 65536
|
||||
|| s.description.as_ref().is_some_and(|s| s.len() > 4096)
|
||||
{
|
||||
return Err(format!("snippets: invalid entry {name:?}"));
|
||||
}
|
||||
entries.push(serde_json::json!({"name":name,"prefixes":prefixes,"body":body,"description":s.description.unwrap_or_default()}));
|
||||
}
|
||||
let key = d.path.to_string_lossy().into_owned();
|
||||
let mut out = UiDecl::new(
|
||||
key.clone(),
|
||||
d.language.clone(),
|
||||
serde_json::json!({"source":key,"language":d.language,"entries":entries}),
|
||||
);
|
||||
out.files.push(d.path);
|
||||
Ok(out)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_every_snippet_at_install_and_requires_api4() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest = |api| {
|
||||
ide_extension::Manifest::parse(&format!(
|
||||
"id='test'\nname='Test'\nversion='1.0.0'\nengines.openidea='{api}'"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let decl: toml::Value = toml::from_str("language='sql'\npath='snippets.json'").unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("snippets.json"),
|
||||
r#"{"Select":{"prefix":["sel","select"],"body":["SELECT ${1:column}","FROM $1$0"],"description":"Query"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pkg = Package::new(manifest("^1.4"), dir.path().into());
|
||||
let out = declaration(&pkg, decl.clone()).unwrap();
|
||||
assert_eq!(out.json["entries"][0]["body"], "SELECT ${1:column}\nFROM $1$0");
|
||||
assert!(declaration(&Package::new(manifest("^1.3"), dir.path().into()), decl.clone()).is_err());
|
||||
for text in [
|
||||
r#"{"x":{"prefix":[],"body":"x"}}"#,
|
||||
r#"{"x":{"prefix":"x","body":42}}"#,
|
||||
r#"{"x":{"prefix":"x","body":"x","extra":1}}"#,
|
||||
] {
|
||||
std::fs::write(dir.path().join("snippets.json"), text).unwrap();
|
||||
assert!(declaration(&pkg, decl.clone()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub use ide_plugin::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A line a view's `process` body printed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -164,8 +165,33 @@ pub struct PluginUpdate {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginEvent {
|
||||
/// Project target registrations or remembered prompt answers changed.
|
||||
RunTargetsChanged,
|
||||
/// A window-owned operation; an empty request id queues an asynchronous run.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
WindowRequest { plugin_id: String, request_id: String, request: WindowRequest },
|
||||
/// Reply to a content action in this window.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply },
|
||||
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
/// Complete replacement of one plugin's project-scoped decorations.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DecorationsChanged { plugin_id: String, sets: Vec<DecorationSet> },
|
||||
/// Open a file in the invoking window.
|
||||
EditorOpen { path: ide_core::IpcPath, selection: Option<ide_core::Range> },
|
||||
/// Change selection or scrolling only in this window's already open editor.
|
||||
EditorRanges { path: ide_core::IpcPath, ranges: Vec<ide_core::Range>, reveal: bool },
|
||||
/// A bounded output channel snapshot, or withdrawal when the plugin stops.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OutputChanged { plugin_id: String, channel: String, text: Option<String> },
|
||||
/// Reveal a read-only Output tab in the receiving window.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RevealOutput { plugin_id: String, channel: String, text: String },
|
||||
/// Execute an action through the receiving window and answer over IPC.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ExecuteCommand { request_id: String, id: String, arg: Option<String>, chain: Vec<String> },
|
||||
/// Cached document associations were disposed (reload, disable or failure).
|
||||
DocumentIndexesChanged,
|
||||
/// The routes `plugin_id` reports for the project `root` may have changed:
|
||||
@@ -187,6 +213,9 @@ pub enum PluginEvent {
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
/// An override in the receiving window; removal exposes the global item.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
WindowStatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
/// A plugin's context key was set (`value`: JSON text) or removed, in the
|
||||
/// emitted window (every window when the client is `None`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -340,6 +369,8 @@ pub struct PackageFile {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStatusItem {
|
||||
/// Whether this item overrides the global item in the requesting window.
|
||||
pub window: bool,
|
||||
pub plugin_id: String,
|
||||
pub id: String,
|
||||
pub item: StatusItem,
|
||||
@@ -388,3 +419,46 @@ pub struct PluginCommitCheckResult {
|
||||
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// The invoking window's response to a command request.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
|
||||
pub enum CommandAnswer {
|
||||
Value(Option<String>),
|
||||
NotFound,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A plugin-owned editor or project-tree decoration set for one file.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DecorationSet {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub path: ide_core::IpcPath,
|
||||
pub set_id: String,
|
||||
pub tree: bool,
|
||||
pub items: Vec<ide_plugin::Decoration>,
|
||||
pub file: Option<ide_plugin::FileDecoration>,
|
||||
}
|
||||
|
||||
/// A request that the invoking window acknowledges through plugin_answer_command.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum WindowRequest {
|
||||
/// Connect and introspect using the normal Database action in this window.
|
||||
DatabaseConnect { id: String },
|
||||
/// Open and acknowledge validation by the shared content editor.
|
||||
ContentTab { tab: ide_plugin::ContentTab },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Run { operation: String, config: Box<ide_run::RunConfig>, debug: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Terminal {
|
||||
operation: String,
|
||||
key: String,
|
||||
title: String,
|
||||
program: ide_core::IpcPath,
|
||||
args: Vec<String>,
|
||||
env: BTreeMap<String, String>,
|
||||
cwd: ide_core::IpcPath,
|
||||
},
|
||||
}
|
||||
@@ -76,8 +76,18 @@ pub const COMMIT_CHECKS: &str = "commitChecks";
|
||||
const VIEW_ITEM_MENU: &str = "viewItem.context";
|
||||
/// The toolbar above the commit message (Commit tool window and dialog).
|
||||
const COMMIT_MESSAGE_MENU: &str = "commit.message";
|
||||
const MENU_IDS: [&str; 6] =
|
||||
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
|
||||
const MENU_IDS: [&str; 10] = [
|
||||
"editor.context",
|
||||
"projectView.context",
|
||||
"editorTab.context",
|
||||
"tools",
|
||||
VIEW_ITEM_MENU,
|
||||
COMMIT_MESSAGE_MENU,
|
||||
"editor.title",
|
||||
"mainToolbar",
|
||||
"problems.context",
|
||||
"searchResults.context",
|
||||
];
|
||||
const MAX_TITLE: usize = 256;
|
||||
const MAX_SETTINGS: usize = 200;
|
||||
const MAX_PATTERNS: usize = 16;
|
||||
@@ -135,6 +145,9 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
|
||||
}
|
||||
declared_command(pkg, MENUS, &d.command)?;
|
||||
if ["editor.title", "mainToolbar", "problems.context", "searchResults.context"].contains(&d.menu.as_str()) {
|
||||
pkg.manifest.needs_api("contributes.menus: language workbench menus", 4)?;
|
||||
}
|
||||
if d.menu == COMMIT_MESSAGE_MENU {
|
||||
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
|
||||
}
|
||||
@@ -965,6 +978,20 @@ pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, Strin
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api4_menu_locations_require_api4_and_declared_commands() {
|
||||
for location in ["editor.title", "mainToolbar", "problems.context", "searchResults.context"] {
|
||||
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
|
||||
let manifest=ide_extension::Manifest::parse(&format!("id='test'\nname='Test'\nversion='1.0.0'\nengines.openidea='{api}'\n[[contributes.commands]]\nid='check'\ntitle='Check'")).unwrap();
|
||||
let pkg = Package::new(manifest, std::path::PathBuf::from("/unused"));
|
||||
let decl =
|
||||
toml::from_str(&format!("menu='{location}'\ncommand='check'\nwhen='editorLang == sql'")).unwrap();
|
||||
assert_eq!(menu(&pkg, decl).is_ok(), valid, "{api}:{location}");
|
||||
assert!(menu(&pkg, toml::from_str(&format!("menu='{location}'\ncommand='foreign'")).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindings_need_a_modifier() {
|
||||
for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] {
|
||||
|
||||
@@ -16,6 +16,7 @@ use ide_extension::{Package, check_relative};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Most files under one web root.
|
||||
const MAX_FILES: usize = 4096;
|
||||
@@ -135,7 +136,7 @@ impl PluginHost {
|
||||
command: WebPtyCommand,
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, || {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, None, || {
|
||||
self.web_pty_options(plugin_id, view, window, command)
|
||||
})
|
||||
}
|
||||
@@ -154,19 +155,21 @@ impl PluginHost {
|
||||
(cols, rows): (u16, u16),
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, || {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, None, || {
|
||||
self.log_tab_pty_options(plugin_id, key, window, on, (cols, rows))
|
||||
})
|
||||
}
|
||||
|
||||
/// The PTY of a plugin, counted against its sessions and ended with its
|
||||
/// window, project or permission; `options` authorizes it.
|
||||
fn spawn_plugin_pty(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn spawn_plugin_pty(
|
||||
&self,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
plugin_id: &str,
|
||||
window: &str,
|
||||
output: crate::ops::Emit<String>,
|
||||
completion: Option<Arc<dyn Fn(Option<i32>) + Send + Sync>>,
|
||||
options: impl FnOnce() -> IdeResult<ide_terminal::TerminalOptions>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
@@ -181,10 +184,19 @@ impl PluginHost {
|
||||
return Err(IdeError::invalid("a plugin may own at most 8 PTYs"));
|
||||
}
|
||||
let cwd = options.cwd.0.clone();
|
||||
let previous = host.window_sink(window);
|
||||
let sink: Arc<dyn ide_core::EventSink> = Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if let ide_core::IdeEvent::TerminalExited { code, .. } = &event
|
||||
&& let Some(completion) = &completion
|
||||
{
|
||||
completion(*code);
|
||||
}
|
||||
previous.emit(event);
|
||||
});
|
||||
let info = host.terminals.spawn_program(
|
||||
options,
|
||||
host.owner_window(window).to_owned(),
|
||||
host.window_sink(window),
|
||||
sink,
|
||||
Box::new(move |data| output(data)),
|
||||
)?;
|
||||
if !self
|
||||
@@ -258,7 +270,7 @@ impl PluginHost {
|
||||
|
||||
/// Checks `command` against the plugin's current grant, the limits and
|
||||
/// the window's trusted project.
|
||||
fn pty_options(
|
||||
pub(super) fn pty_options(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
window: &str,
|
||||
@@ -309,7 +321,7 @@ impl PluginHost {
|
||||
if !workspace.trusted(&cwd) {
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
|
||||
}
|
||||
let program = self.program_for(plugin_id, &command.program)?;
|
||||
let program = self.program_for_project(plugin_id, &command.program, Some(&cwd))?;
|
||||
Ok(ide_terminal::TerminalOptions {
|
||||
cwd: cwd.into(),
|
||||
shell: Some(program.to_string_lossy().into_owned()),
|
||||
|
||||
@@ -248,7 +248,7 @@ impl Dispatcher {
|
||||
.add("term_is_busy", |c| async move { ops::term::is_busy(&c.host, c.arg("id")?) })
|
||||
.add("term_default_shell", |_| async move { Ok(ide_terminal::default_shell_path()) })
|
||||
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
|
||||
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
|
||||
.add("run_configs", |c| async move { ops::doc::blocking(move || ops::run::configs(&c.host, &c.window)).await })
|
||||
.add("run_save_configs", |c| async move {
|
||||
ops::run::save_configs(
|
||||
&c.host,
|
||||
@@ -286,6 +286,10 @@ where
|
||||
let p = Peer::connect(reader, writer, handler);
|
||||
let _ = peer.set(p.clone());
|
||||
p.closed().await;
|
||||
// Reports belong to the disconnected desktop, even while the host shuts down.
|
||||
for window in host.projects.read().keys() {
|
||||
host.mcp_reports.forget_window(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a window's events to the client as `event` notifications.
|
||||
|
||||
@@ -146,6 +146,9 @@ impl crate::Host {
|
||||
phpstan.interrupt();
|
||||
}
|
||||
if !trusted {
|
||||
if let Some(plugins) = self.plugin_host() {
|
||||
plugins.withdraw_project_run_targets(&project.root);
|
||||
}
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
project.repos();
|
||||
@@ -396,6 +399,8 @@ pub fn projectless_command_allowed(cmd: &str) -> bool {
|
||||
| "plugin_set_enabled"
|
||||
| "plugin_grant"
|
||||
| "plugin_set_capability"
|
||||
| "plugin_choose_program"
|
||||
| "plugin_program"
|
||||
| "plugin_roll_back"
|
||||
| "plugin_reload"
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ fn fixture() -> Fixture {
|
||||
|
||||
fn inv() -> Invocation {
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some("main".into()),
|
||||
workspace_root: None,
|
||||
editor: None,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Database and MCP calls through a real sandboxed API 1.4 component.
|
||||
use ide_core::{CancellationToken, IdeEvent, event::McpUiRequest};
|
||||
use ide_host::{
|
||||
Host, HostConfig,
|
||||
mcp::Registry,
|
||||
ops,
|
||||
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost, WindowRequest},
|
||||
};
|
||||
use ide_mcp::{ToolCallError, ToolRegistry};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
const ID: &str = "test.data-tools";
|
||||
fn fixture() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/data-tools")
|
||||
}
|
||||
struct Harness {
|
||||
host: Arc<Host>,
|
||||
plugins: Arc<PluginHost>,
|
||||
root: PathBuf,
|
||||
events: Arc<Mutex<Vec<(String, IdeEvent)>>>,
|
||||
requests: Arc<Mutex<Vec<(String, PluginEvent)>>>,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
let out = events.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| {
|
||||
let (w, out) = (w.to_owned(), out.clone());
|
||||
Arc::new(move |e| out.lock().unwrap().push((w.clone(), e)))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
std::fs::write(root.join("db.sqlite"), "").unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.open_project("second", &root).unwrap();
|
||||
let requests = Arc::new(Mutex::new(vec![]));
|
||||
let out = requests.clone();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(
|
||||
tmp.path().join("plugins"),
|
||||
Arc::new(move |w: Option<&str>, e| out.lock().unwrap().push((w.unwrap_or_default().to_owned(), e))),
|
||||
Limits { timeout: Duration::from_millis(250), ..Default::default() },
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
||||
host.set_plugin_host(&plugins);
|
||||
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
Self { host, plugins, root, events, requests, _tmp: tmp }
|
||||
}
|
||||
fn invocation(&self) -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(self.root.clone()), ..Default::default() }
|
||||
}
|
||||
fn run(&self, id: &str, arg: Option<&str>) -> ide_core::IdeResult<Option<String>> {
|
||||
self.plugins.run_command(ID, id, arg, self.invocation())
|
||||
}
|
||||
fn source(&self) -> String {
|
||||
serde_json::json!({"id":"local","name":"Example","driver":"sqlite","database":self.root.join("db.sqlite").to_string_lossy(),"options":[]}).to_string()
|
||||
}
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn database_calls_enforce_grants_trust_ownership_and_uninstall_lifetime() {
|
||||
let f = Harness::new();
|
||||
assert!(f.run("sources", None).is_err());
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let source = f.source();
|
||||
f.run("upsert", Some(&source)).unwrap();
|
||||
assert_eq!(f.run("sources", None).unwrap().as_deref(), Some("[\"local\"]"));
|
||||
f.run("credential", Some("never-on-disk")).unwrap();
|
||||
let source = f.host.project("main").unwrap().db.plugin_source(ID, "local").unwrap();
|
||||
let store = ide_db::secrets::SecretStore::new(f._tmp.path().join("data/db-passwords.json"), &f.root);
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("never-on-disk"));
|
||||
let file = f.root.join(".openidea/data-sources.toml");
|
||||
assert!(!std::fs::read_to_string(&file).unwrap().contains("never-on-disk"));
|
||||
for command in ["remove", "connect"] {
|
||||
assert!(f.run(command, Some("foreign")).is_err());
|
||||
}
|
||||
assert!(f.events.lock().unwrap().iter().any(|(w, e)| w == "second" && matches!(e, IdeEvent::DbSourcesChanged)));
|
||||
f.plugins.set_enabled(ID, false).unwrap();
|
||||
assert!(file.exists());
|
||||
f.plugins.set_enabled(ID, true).unwrap();
|
||||
assert!(f.host.project("main").unwrap().db.plugin_source(ID, "local").is_ok());
|
||||
f.host.set_mcp_executable(Some(PathBuf::from("/app/openidea")));
|
||||
let command: serde_json::Value = serde_json::from_str(&f.run("stdio", None).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(command[1], "--mcp-stdio");
|
||||
f.host.set_mcp_executable(None);
|
||||
assert_eq!(f.run("stdio", None).unwrap().as_deref(), Some("null"));
|
||||
// Closed project cleanup uses the host-owned journal, not only current windows.
|
||||
f.host.close_project("main");
|
||||
f.host.close_project("second");
|
||||
f.plugins.uninstall(ID).unwrap();
|
||||
assert!(ide_db::source::load(&f.root).unwrap().is_empty());
|
||||
assert!(store.get(&source.id).is_none());
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn database_connect_asks_only_the_invoking_window_and_uses_normal_connection_flow() {
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
f.run("upsert", Some(&f.source())).unwrap();
|
||||
let (plugins, invocation) = (f.plugins.clone(), f.invocation());
|
||||
let run = tokio::task::spawn_blocking(move || plugins.run_command(ID, "connect", None, invocation));
|
||||
let (window, request, source) = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let found = f.requests.lock().unwrap().iter().find_map(|(w, e)| match e {
|
||||
PluginEvent::WindowRequest { request_id, request: WindowRequest::DatabaseConnect { id }, .. } => {
|
||||
Some((w.clone(), request_id.clone(), id.clone()))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(v) = found {
|
||||
break v;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(window, "main");
|
||||
assert!(!f.plugins.answer_command("second", &request, ide_host::plugins::CommandAnswer::Value(None)));
|
||||
tokio::time::sleep(Duration::from_millis(350)).await; // Password entry can outlive the 250 ms command budget.
|
||||
ops::db::connect(&f.host, "main", &source).await.unwrap();
|
||||
f.plugins.answer_command("main", &request, ide_host::plugins::CommandAnswer::Value(None));
|
||||
run.await.unwrap().unwrap();
|
||||
assert!(!f.host.project("main").unwrap().db.connected().is_empty());
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_listing_dispatch_confirmation_and_lifecycle_use_plugin_authority() {
|
||||
let f = Harness::new();
|
||||
let registry = Arc::new(Registry::new(f.host.clone()));
|
||||
assert!(!registry.tools(&f.root).unwrap().iter().any(|t| t.name == "test_data_tools__write"));
|
||||
assert!(registry.call(&f.root, "test_data_tools__write", "{}", CancellationToken::new()).is_err());
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let tools = registry.tools(&f.root).unwrap();
|
||||
let name = "test_data_tools__echo";
|
||||
assert!(tools.iter().any(|t| t.name == name && t.description.contains(ID)));
|
||||
assert!(matches!(
|
||||
registry.call(&f.root, name, r#"{"text":12}"#, CancellationToken::new()),
|
||||
Err(ToolCallError::Failed(_))
|
||||
));
|
||||
let result = registry.call(&f.root, name, r#"{"text":"hello"}"#, CancellationToken::new()).unwrap();
|
||||
assert!(result.contains("hello"));
|
||||
let denied = registry.call(&f.root, "test_data_tools__denied", "{}", CancellationToken::new()).unwrap_err();
|
||||
assert!(format!("{denied:?}").contains("process:sh"));
|
||||
f.host.mcp_requests.set_ready("main", true);
|
||||
let (r, root) = (registry.clone(), f.root.clone());
|
||||
let call =
|
||||
tokio::task::spawn_blocking(move || r.call(&root, "test_data_tools__erase", "{}", CancellationToken::new()));
|
||||
let request = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(id) = f.events.lock().unwrap().iter().find_map(|(_, e)| match e {
|
||||
IdeEvent::McpRequest { id, request: McpUiRequest::Confirm { .. } } => Some(*id),
|
||||
_ => None,
|
||||
}) {
|
||||
break id;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(f.host.mcp_requests.answer("second", request, true, None).is_err());
|
||||
f.host.mcp_requests.answer("main", request, false, None).unwrap();
|
||||
assert!(call.await.unwrap().is_err());
|
||||
f.plugins.set_enabled(ID, false).unwrap();
|
||||
assert!(!registry.tools(&f.root).unwrap().iter().any(|t| t.name == name));
|
||||
assert!(registry.call(&f.root, name, "{}", CancellationToken::new()).is_err());
|
||||
f.plugins.set_enabled(ID, true).unwrap();
|
||||
assert!(registry.tools(&f.root).unwrap().iter().any(|t| t.name == name));
|
||||
let (r, root) = (registry.clone(), f.root.clone());
|
||||
let spin =
|
||||
tokio::task::spawn_blocking(move || r.call(&root, "test_data_tools__spin", "{}", CancellationToken::new()));
|
||||
assert!(spin.await.unwrap().is_err(), "command budget interrupts wasm");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn database_imports_are_denied_without_the_optional_grant() {
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
f.plugins.set_capability(ID, "database.sources", false).unwrap();
|
||||
for command in ["upsert", "remove", "sources", "credential", "connect"] {
|
||||
let err = f.run(command, Some(&f.source())).unwrap_err();
|
||||
assert!(err.message.contains("database.sources"), "{command}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn official_api_examples_install_and_render_real_component_contributions() {
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let official = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official");
|
||||
for name in ["blame-annotations", "todo-finder"] {
|
||||
f.plugins.install_from_dir(&official.join(name), &Consent::all()).unwrap();
|
||||
}
|
||||
std::fs::write(f.root.join("notes.txt"), "TODO example\n").unwrap();
|
||||
let doc = ide_plugin::ProviderDocument {
|
||||
path: f.root.join("notes.txt"),
|
||||
language: "text".into(),
|
||||
version: 1,
|
||||
text: "TODO example\n".into(),
|
||||
};
|
||||
assert!(eventually(|| {
|
||||
f.plugins
|
||||
.language_code_lenses(&doc)
|
||||
.iter()
|
||||
.any(|(owner, lens)| owner == "openidea.blame-annotations" && lens.command == "annotate")
|
||||
}));
|
||||
f.plugins.run_command("openidea.todo-finder", "find", None, f.invocation()).unwrap();
|
||||
let view = f.plugins.render_view("openidea.todo-finder", "todos", None, f.invocation()).unwrap();
|
||||
let ide_plugin::ViewBody::List(items) = view.body else { panic!("expected TODO list") };
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(items[0].label.contains("TODO example"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = std::env::join_paths([official.join("container-runner"), official.join("test-runner")]).unwrap();
|
||||
f.plugins.set_program_path(Arc::new(move || Some(path.clone())));
|
||||
for name in ["container-runner", "test-runner"] {
|
||||
f.plugins.install_from_dir(&official.join(name), &Consent::all()).unwrap();
|
||||
}
|
||||
std::fs::write(f.root.join("example.tests"), "test adds\n").unwrap();
|
||||
let doc =
|
||||
ide_plugin::ProviderDocument { path: f.root.join("example.tests"), text: "test adds\n".into(), ..doc };
|
||||
assert!(eventually(|| {
|
||||
f.plugins
|
||||
.language_gutter_actions(&doc)
|
||||
.iter()
|
||||
.any(|(owner, action)| owner == "openidea.test-runner" && action.argument.as_deref() == Some("adds"))
|
||||
}));
|
||||
f.plugins.refresh_run_configurations(&f.root);
|
||||
let configs = f.plugins.run_detection().detect(&f.root);
|
||||
assert!(configs.iter().any(|c| c.id == "plugin:openidea.test-runner:tests"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor-path calls drop a result that misses their budget, as the first call into a cold
|
||||
/// component can on a slow machine: ask again until it arrives.
|
||||
fn eventually(mut ready: impl FnMut() -> bool) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if ready() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -20,7 +20,7 @@ impl Fixture {
|
||||
}
|
||||
fn with_manifest(edit: impl FnOnce(&Path)) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
host.set_project_policy(Arc::new(|_| true));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
|
||||
@@ -147,6 +147,48 @@ fn permissions_and_paths_are_checked_before_indexing() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn verbatim_snapshots_use_the_hosts_plain_document_identity() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "app");
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
|
||||
let verbatim = std::fs::canonicalize(&source).unwrap();
|
||||
assert_ne!(source, verbatim);
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &verbatim, version: Some(2), text: SOURCE, symbols: &[method()], scope: "app" },
|
||||
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets.len(), 1, "a different prefix must replace the same document snapshot");
|
||||
assert_eq!(result.targets[0].path.0, source);
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &source, Pos::new(0, 4), None).is_some());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn snapshot_paths_still_reject_symlink_aliases_and_escapes() {
|
||||
let f = Fixture::new();
|
||||
let path = f.index("source.idx", SOURCE, &[method()], Some(1), "app");
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
for (target, name) in [(path.as_path(), "alias.idx"), (outside.path(), "escape.idx")] {
|
||||
let alias = f.root.join(name);
|
||||
std::os::unix::fs::symlink(target, &alias).unwrap();
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &alias, version: None, text: SOURCE, symbols: &[], scope: "app" },
|
||||
Invocation::default(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() {
|
||||
let f = Fixture::new();
|
||||
@@ -194,6 +236,7 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
|
||||
&f.root,
|
||||
Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" },
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some(client.into()),
|
||||
workspace_root: Some(f.root.clone()),
|
||||
editor: None,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! The API 1.4 fixture exercises every kind through the real component runtime and editor ops.
|
||||
use ide_core::{CancellationToken, Pos, Range, RequestId};
|
||||
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, set_editor_providers};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_plugin::{Invocation, Limits, ProviderDocument};
|
||||
use std::{path::Path, sync::Arc, time::Duration};
|
||||
const ID: &str = "test.language-features";
|
||||
fn fixture() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/language-features")
|
||||
}
|
||||
fn plugins(base: &Path) -> Arc<PluginHost> {
|
||||
let p = Arc::new(
|
||||
PluginHost::new(
|
||||
base.join("plugins"),
|
||||
Arc::new(|_: Option<&str>, _| {}),
|
||||
Limits { edit_soft: Duration::from_secs(3), edit_hard: Duration::from_secs(5), ..Default::default() },
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
p
|
||||
}
|
||||
#[test]
|
||||
fn injections_route_only_declared_embedded_providers_and_filter_diagnostics() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = plugins(tmp.path());
|
||||
let doc = ProviderDocument {
|
||||
path: tmp.path().join("a.txt"),
|
||||
language: "text".into(),
|
||||
version: 9,
|
||||
text: "xx[[sql]]zz".into(),
|
||||
};
|
||||
assert_eq!(p.provide_injections(&doc).len(), 1);
|
||||
let items = p.provide_completion(&doc, Pos::new(0, 5), None);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].1.label, "sql: sql ");
|
||||
assert!(p.provide_completion(&doc, Pos::new(0, 1), None).is_empty());
|
||||
assert_eq!(p.provide_hover(&doc, Pos::new(0, 5))[0].markdown, "sql: sql ");
|
||||
let diagnostics = p.provide_diagnostics(&doc, &CancellationToken::new());
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].1.len(), 1);
|
||||
assert_eq!(diagnostics[0].1[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7)));
|
||||
assert!(p.language_signature_help(&ProviderDocument { language: "rust".into(), ..doc }, Pos::default()).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn every_new_kind_requires_api4_at_install() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = plugins(tmp.path());
|
||||
let package = tmp.path().join("old");
|
||||
std::fs::create_dir_all(package.join("dist")).unwrap();
|
||||
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
|
||||
let header = "id='old'\nname='Old'\nversion='1.0.0'\nengines.openidea='^1.3'\nwasm='dist/plugin.wasm'\n";
|
||||
for kind in [
|
||||
"formatting",
|
||||
"signature-help",
|
||||
"document-highlights",
|
||||
"document-links",
|
||||
"code-lenses",
|
||||
"selection-ranges",
|
||||
"folding",
|
||||
"colors",
|
||||
"document-symbols",
|
||||
"implementation",
|
||||
"type-definition",
|
||||
"gutter-actions",
|
||||
"injections",
|
||||
] {
|
||||
std::fs::write(
|
||||
package.join("plugin.toml"),
|
||||
format!("{header}\n[[contributes.providers]]\nkinds=['{kind}']\nlanguages=['text']"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(p.install_from_dir(&package, &Consent::all()).is_err(), "{kind}");
|
||||
}
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.open_project("main", &root).unwrap();
|
||||
let p = plugins(tmp.path());
|
||||
p.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
||||
set_editor_providers(Some(&p));
|
||||
let path = root.join("a.txt");
|
||||
std::fs::write(&path, "value\nnext\nend").unwrap();
|
||||
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
|
||||
.parameters,
|
||||
vec![(6, 11)]
|
||||
);
|
||||
assert_eq!(ops::lsp::lsp_document_highlights(&host, "main", doc.id, Pos::default()).await.unwrap().len(), 1);
|
||||
let links = ops::lsp::lsp_document_links(&host, "main", doc.id).await.unwrap();
|
||||
assert_eq!(links.len(), 2);
|
||||
assert_eq!(links[0].path.as_ref().unwrap().0, root.join("next.txt"));
|
||||
let lenses = ops::lsp::lsp_code_lens(&host, "main", doc.id).await.unwrap();
|
||||
assert_eq!(lenses.len(), 1, "undeclared command was dropped");
|
||||
ops::lsp::lsp_execute_command(&host, "main", doc.id, lenses[0].command.clone().unwrap()).await.unwrap();
|
||||
assert_eq!(p.run_command(ID, "state", None, Invocation::default()).unwrap().as_deref(), Some("lens"));
|
||||
let outline = ops::doc::doc_outline(&host, "main", doc.id).await.unwrap();
|
||||
assert_eq!(outline[0].name, "Outer");
|
||||
assert_eq!(outline[0].children[0].name, "Inner");
|
||||
assert_eq!(ops::doc::doc_folding(&host, "main", doc.id).await.unwrap()[0].end_line, 2);
|
||||
assert_eq!(ops::doc::doc_selection_ranges(&host, "main", doc.id, 0).await.unwrap().len(), 2);
|
||||
for kind in [ide_lsp::types::GotoKind::Implementation, ide_lsp::types::GotoKind::TypeDefinition] {
|
||||
assert_eq!(ops::lsp::lsp_goto(&host, "main", doc.id, Pos::default(), kind).await.unwrap().len(), 1);
|
||||
}
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_goto_implementation(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(1),
|
||||
doc.id,
|
||||
Pos::default(),
|
||||
ide_host::ops::Channel::new(|_| {})
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let visuals = ops::lsp::plugin_language_visuals(&host, "main", doc.id).await.unwrap();
|
||||
assert_eq!(visuals.colors.len(), 1);
|
||||
assert_eq!(visuals.gutter_actions.len(), 1);
|
||||
assert_eq!(
|
||||
ops::lsp::plugin_color_presentations(&host, "main", doc.id, ID.into(), visuals.colors[0].1.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
["red-plaintext", "#ff0000"]
|
||||
);
|
||||
let project = host.project("main").unwrap();
|
||||
project
|
||||
.settings
|
||||
.set(
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"languages.pluginFormatters",
|
||||
serde_json::json!({doc.language.as_str():ID}),
|
||||
)
|
||||
.unwrap();
|
||||
let options = ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None };
|
||||
let formatted =
|
||||
ops::lsp::lsp_format(&host, "main", doc.id, Some(Range::new(Pos::default(), Pos::new(0, 5))), options)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(formatted[0].new_text, "formatted");
|
||||
// The same editor ops are exposed by the server dispatcher, with no desktop provider call.
|
||||
let dispatcher = ide_host::rpc::Dispatcher::standard();
|
||||
assert!(dispatcher.commands().contains(&"plugin_language_visuals"));
|
||||
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(std::sync::OnceLock::new());
|
||||
let remote = ide_host::plugins::remote_host::RemotePlugins::start(&base, &host, peer_slot.clone()).unwrap();
|
||||
let mut dispatcher = ide_host::rpc::Dispatcher::standard();
|
||||
remote.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(ide_host::rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
||||
struct Client;
|
||||
impl ide_jsonrpc::Handler for Client {
|
||||
fn on_notification(&self, _: &str, _: serde_json::Value) {}
|
||||
fn on_request(&self, method: &str, _: serde_json::Value) -> Result<serde_json::Value, (i64, String)> {
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
|
||||
}
|
||||
}
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = ide_jsonrpc::Peer::connect(cr, cw, Arc::new(Client));
|
||||
let _: serde_json::Value =
|
||||
peer.request("initialize", serde_json::json!({"version":ide_host::rpc::PROTOCOL_VERSION}), None).await.unwrap();
|
||||
async fn invoke(peer: &ide_jsonrpc::Peer, cmd: &str, args: serde_json::Value) -> serde_json::Value {
|
||||
peer.request(
|
||||
"invoke",
|
||||
serde_json::to_value(ide_host::rpc::Invoke { window: "main".into(), cmd: cmd.into(), args }).unwrap(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
invoke(&peer, "project_trust_set", serde_json::json!({"path":root,"trusted":true,"folder":false})).await;
|
||||
let files = serde_json::json!({ID:p.remote_package_files(ID).unwrap()});
|
||||
let synced = invoke(
|
||||
&peer,
|
||||
ide_host::plugins::remote_host::SYNC_PACKAGES,
|
||||
serde_json::json!({"packages":p.remote_packages(),"files":files}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(synced["failed"], serde_json::json!([]), "{synced}");
|
||||
let listed = invoke(&peer, "plugin_formatters", serde_json::json!({})).await;
|
||||
assert_eq!(listed[0]["id"], ID);
|
||||
let visual = invoke(&peer, "plugin_language_visuals", serde_json::json!({"doc":doc.id})).await;
|
||||
assert_eq!(visual["gutterActions"].as_array().unwrap().len(), 1);
|
||||
set_editor_providers(Some(&p));
|
||||
p.set_enabled(ID, false).unwrap();
|
||||
assert!(ops::lsp::plugin_formatters().is_empty());
|
||||
set_editor_providers(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting_uses_the_command_budget_while_editor_calls_can_expire() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = PluginHost::new(
|
||||
tmp.path().join("plugins"),
|
||||
Arc::new(|_: Option<&str>, _| {}),
|
||||
Limits { edit_soft: Duration::ZERO, ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
p.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
||||
let doc =
|
||||
ProviderDocument { path: tmp.path().join("a.txt"), language: "text".into(), version: 1, text: "value".into() };
|
||||
assert_eq!(
|
||||
p.language_format(
|
||||
ID,
|
||||
&doc,
|
||||
None,
|
||||
ide_plugin::language_features::FormatOptions { tab_size: 4, insert_spaces: true }
|
||||
)
|
||||
.unwrap()[0]
|
||||
.new_text,
|
||||
"formatted"
|
||||
);
|
||||
assert!(p.language_signature_help(&doc, Pos::default()).is_none());
|
||||
assert!(p.info_of(ID).unwrap().stats.slow_calls > 0);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use std::{path::Path, sync::Arc};
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let first = root.join("definition.idx");
|
||||
let second = root.join("usage.idx");
|
||||
let source = "def 😀route\nref 😀route\n";
|
||||
@@ -35,9 +35,7 @@ async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_light("main", &first).unwrap();
|
||||
// Editors retain ordinary Windows paths; index snapshots use canonical
|
||||
// (verbatim on Windows) paths and the host's own project-root identity.
|
||||
let doc = project.documents.open(dunce::simplified(&first)).unwrap();
|
||||
let doc = project.documents.open(&first).unwrap();
|
||||
let plugins =
|
||||
Arc::new(PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap());
|
||||
plugins.set_project_policy(Arc::new(|_| true));
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
use ide_core::{CancellationToken, Diagnostic, Pos, Range, Severity};
|
||||
use ide_host::plugins::{HostWorkspace, PluginWorkspace};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_plugin::TextQuery;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Harness {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.open_project("second", &root).unwrap();
|
||||
Self { host, root, _tmp: tmp }
|
||||
}
|
||||
fn workspace(&self) -> HostWorkspace {
|
||||
HostWorkspace(Arc::downgrade(&self.host))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn window_editor_reports_use_document_language_dirty_state_and_focus() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("main.rs");
|
||||
std::fs::write(&path, "fn main() {}\n").unwrap();
|
||||
let p = f.host.project("main").unwrap();
|
||||
let doc = p.documents.open(&path).unwrap();
|
||||
p.documents
|
||||
.apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "// unsaved\n".into() }])
|
||||
.unwrap();
|
||||
f.host.mcp_reports.set_open_editors("main", vec![path.clone()]);
|
||||
f.host.mcp_reports.set_focused_editor(
|
||||
"main",
|
||||
Some(ide_host::mcp::FocusedEditor {
|
||||
path: path.clone().into(),
|
||||
selection: Range::default(),
|
||||
selected_text: String::new(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
let workspace = f.workspace();
|
||||
let editors = workspace.open_editors("main").unwrap();
|
||||
assert_eq!(editors.len(), 1);
|
||||
assert!(editors[0].dirty && editors[0].active);
|
||||
assert_eq!(editors[0].language, "rust");
|
||||
assert!(workspace.open_editors("second").unwrap().is_empty());
|
||||
let second = f.host.project("second").unwrap();
|
||||
second.documents.open(&path).unwrap();
|
||||
f.host.mcp_reports.set_open_editors("second", vec![path.clone()]);
|
||||
assert!(!workspace.open_editors("second").unwrap()[0].dirty);
|
||||
assert!(workspace.open_editors("main").unwrap()[0].dirty);
|
||||
assert!(workspace.buffer_text_in(Some("main"), &path).unwrap().contains("unsaved"));
|
||||
assert!(!workspace.buffer_text_in(Some("second"), &path).unwrap().contains("unsaved"));
|
||||
assert_ne!(workspace.document_in("main", &path), workspace.document_in("second", &path));
|
||||
f.host.mcp_reports.forget_window("main");
|
||||
assert!(workspace.open_editors("main").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn text_search_uses_overlay_excludes_utf16_and_limits() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("a.txt");
|
||||
std::fs::write(&path, "disk\n").unwrap();
|
||||
std::fs::create_dir(f.root.join("target")).unwrap();
|
||||
std::fs::write(f.root.join("target/ignored.txt"), "needle").unwrap();
|
||||
let p = f.host.project("main").unwrap();
|
||||
p.settings
|
||||
.set(ide_workspace::settings::SettingsScope::Project, "files.ignoredNames", serde_json::json!(["target"]))
|
||||
.unwrap();
|
||||
let doc = p.documents.open(&path).unwrap();
|
||||
p.documents
|
||||
.apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "😀needle\nneedle\n".into() }])
|
||||
.unwrap();
|
||||
f.host.project("second").unwrap().documents.open(&path).unwrap();
|
||||
let workspace = f.workspace();
|
||||
let query = TextQuery { pattern: "needle".into(), case_sensitive: true, ..Default::default() };
|
||||
let found = workspace.search_text(Some("main"), &f.root, query.clone(), 10, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(found.matches.len(), 2, "{found:?}; text={:?}", workspace.buffer_text(&path));
|
||||
assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8)));
|
||||
assert!(found.matches.iter().all(|m| m.location.path == path));
|
||||
let only_file = TextQuery { include: vec!["a.txt".into()], ..query.clone() };
|
||||
assert!(
|
||||
workspace
|
||||
.search_text(Some("second"), &f.root, only_file, 10, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.matches
|
||||
.is_empty()
|
||||
);
|
||||
let capped = workspace.search_text(Some("main"), &f.root, query.clone(), 1, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(capped.matches.len(), 1);
|
||||
assert!(capped.limit_hit);
|
||||
let excluded = TextQuery { exclude: vec!["*.txt".into()], ..query.clone() };
|
||||
assert!(
|
||||
workspace
|
||||
.search_text(Some("main"), &f.root, excluded, 10, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.matches
|
||||
.is_empty()
|
||||
);
|
||||
for query in [
|
||||
TextQuery { pattern: "NEEDLE".into(), case_sensitive: true, ..Default::default() },
|
||||
TextQuery { pattern: "need".into(), whole_word: true, ..Default::default() },
|
||||
TextQuery { include: vec!["*.rs".into()], ..query.clone() },
|
||||
] {
|
||||
assert!(
|
||||
workspace
|
||||
.search_text(Some("main"), &f.root, query, 10, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.matches
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
let multiline = TextQuery { pattern: "needle\nneedle".into(), regex: true, ..Default::default() };
|
||||
let found = workspace.search_text(Some("main"), &f.root, multiline, 10, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(1, 6)));
|
||||
let cancelled = CancellationToken::new();
|
||||
cancelled.cancel();
|
||||
assert!(matches!(
|
||||
workspace.search_text(Some("main"), &f.root, query, 10, &cancelled),
|
||||
Err(ide_plugin::HostError::Cancelled)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn problems_share_project_reports_and_keep_sources_separate() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("a.txt");
|
||||
std::fs::write(&path, "text").unwrap();
|
||||
let diagnostic = |source: &str| Diagnostic {
|
||||
range: Range::default(),
|
||||
severity: Severity::Warning,
|
||||
message: source.into(),
|
||||
source: Some(source.into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: None,
|
||||
};
|
||||
f.host.mcp_reports.report_problems(
|
||||
"second",
|
||||
true,
|
||||
vec![ide_host::mcp::FileProblems {
|
||||
path: path.clone().into(),
|
||||
items: vec![diagnostic("lsp"), diagnostic("plugin")],
|
||||
}],
|
||||
);
|
||||
let workspace = f.workspace();
|
||||
let files = workspace.problems("main", Some(&path)).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
|
||||
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
|
||||
f.host.mcp_reports.forget_window("second");
|
||||
assert!(workspace.problems("main", None).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn search_decodes_closed_documents_the_same_way_as_the_editor() {
|
||||
let f = Harness::new();
|
||||
let path = f.root.join("utf16.txt");
|
||||
let mut bytes = vec![0xff, 0xfe];
|
||||
for unit in "😀needle\r\n".encode_utf16() {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let query = TextQuery { pattern: "needle".into(), ..Default::default() };
|
||||
let result = f.workspace().search_text(Some("main"), &f.root, query, 10, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(result.matches.len(), 1);
|
||||
assert_eq!(result.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8)));
|
||||
assert_eq!(result.matches[0].line, "😀needle");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn plugin_debug_uses_the_source_language_and_revalidates_its_owned_program() {
|
||||
use ide_host::plugins::{Consent, PluginEvent, PluginHost, WindowRequest};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
std::fs::write(f.root.join("execution.marker"), "").unwrap();
|
||||
std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap();
|
||||
let events = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
let captured = events.clone();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(
|
||||
f._tmp.path().join("plugins"),
|
||||
Arc::new(move |client: Option<&str>, event: PluginEvent| {
|
||||
captured.lock().push((client.map(str::to_owned), event))
|
||||
}),
|
||||
Limits::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
||||
f.host.set_plugin_host(&plugins);
|
||||
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
||||
plugins.install_from_dir(&package, &Consent::all()).unwrap();
|
||||
plugins
|
||||
.choose_program("execution", "process:execution-tool", Some(&f.root), Some(std::path::Path::new("/bin/cat")))
|
||||
.unwrap();
|
||||
let mut config =
|
||||
plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap();
|
||||
config.args = vec!["entry.py".into()];
|
||||
ide_run::save_user_configs(&f.root, &[config]).unwrap();
|
||||
let config = f.workspace().run_configuration("main", "execution", "detected").unwrap();
|
||||
assert_eq!(config.debug_adapter.as_deref(), Some("debugpy"));
|
||||
let pinned = plugins.program_for_project("execution", "execution-tool", Some(&f.root)).unwrap();
|
||||
assert_eq!(plugins.prepare_debug_run("main", config.clone()).unwrap().command, pinned.to_string_lossy());
|
||||
let invocation =
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
||||
assert!(plugins.run_command("execution", "debug", None, invocation).unwrap().is_some());
|
||||
assert!(events.lock().iter().any(|(client, event)| client.as_deref() == Some("main") && matches!(event, PluginEvent::WindowRequest { request: WindowRequest::Run { config, debug: true, .. }, .. } if config.command == pinned.to_string_lossy())));
|
||||
plugins.set_capability("execution", "process:execution-tool", false).unwrap();
|
||||
assert!(plugins.prepare_debug_run("main", config).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
|
||||
use ide_host::plugins::{Consent, PluginEvent, PluginHost, run_targets};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(
|
||||
f._tmp.path().join("plugins"),
|
||||
Arc::new(|_: Option<&str>, _: PluginEvent| {}),
|
||||
Limits { idle_timeout: Some(std::time::Duration::from_millis(10)), ..Default::default() },
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
||||
f.host.set_plugin_host(&plugins);
|
||||
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
||||
plugins.install_from_dir(&package, &Consent::all()).unwrap();
|
||||
let tool = f._tmp.path().join("wrapper");
|
||||
std::fs::write(&tool, "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf '<%s>\\n' \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let invocation =
|
||||
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
||||
assert!(plugins.run_command("execution", "target", None, invocation()).is_err());
|
||||
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
plugins.tick_at(std::time::Instant::now());
|
||||
assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive);
|
||||
let targets = run_targets::list(&f.host, &plugins, "main").unwrap();
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert!(targets[0].prompt);
|
||||
assert!(f.host.project("main").unwrap().settings.effective().run.default_target.is_empty());
|
||||
let id = &targets[0].id;
|
||||
let settings = run_targets::answer(&f.host, &plugins, "main", id, false).unwrap();
|
||||
assert!(settings.run.default_target.is_empty());
|
||||
// A stale prompt in a second window cannot change an answered selection.
|
||||
assert!(run_targets::answer(&f.host, &plugins, "second", id, true).unwrap().run.default_target.is_empty());
|
||||
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
assert!(!plugins.run_targets(&f.root)[0].prompt);
|
||||
plugins.run_command("execution", "target", Some("preferred"), invocation()).unwrap();
|
||||
let preferred = plugins.run_targets(&f.root).into_iter().find(|t| t.target.id == "preferred").unwrap();
|
||||
let settings = run_targets::answer(&f.host, &plugins, "main", &preferred.id, true).unwrap();
|
||||
assert_eq!(settings.run.default_target, preferred.id);
|
||||
assert_eq!(settings.languages.php.run_target, preferred.id);
|
||||
assert_eq!(settings.languages.node.run_target, preferred.id);
|
||||
assert_eq!(f.host.project("second").unwrap().settings.effective().run.default_target, preferred.id);
|
||||
std::fs::write(f.root.join("execution.marker"), "").unwrap();
|
||||
std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap();
|
||||
let mut detected =
|
||||
plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap();
|
||||
detected.args = vec!["entry.py".into()];
|
||||
ide_run::save_user_configs(&f.root, &[detected]).unwrap();
|
||||
let mut debug = f.workspace().run_configuration("main", "execution", "detected").unwrap();
|
||||
debug.extras.target = None;
|
||||
let error = plugins.prepare_debug_run("main", debug).unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert!(error.to_string().contains("target"));
|
||||
assert!(plugins.run_command("execution", "debug", None, invocation()).unwrap_err().to_string().contains("target"));
|
||||
let output = Arc::new(parking_lot::Mutex::new(String::new()));
|
||||
let captured = output.clone();
|
||||
let config = ide_run::RunConfig {
|
||||
id: "php-target".into(),
|
||||
name: "PHP target".into(),
|
||||
kind: "php".into(),
|
||||
command: "php".into(),
|
||||
args: vec!["x.php".into(), "a b; $(touch forbidden)".into()],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let run = ide_host::ops::run::start(
|
||||
&f.host,
|
||||
"main",
|
||||
config.clone(),
|
||||
Arc::new(move |chunk| {
|
||||
if let ide_run::OutputChunk::Stdout { text } = chunk {
|
||||
captured.lock().push_str(&text);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..100 {
|
||||
if f.host.processes.list().iter().any(|r| r.id == run.id && !r.running) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
let text = output.lock().clone();
|
||||
assert!(text.contains("<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<a b; $(touch forbidden)>"), "{text}");
|
||||
assert!(text.contains(&format!("cwd={}", f.root.display())), "{text}");
|
||||
assert!(!f.root.join("forbidden").exists());
|
||||
// Pin checks are repeated at launch, not only at registration.
|
||||
std::fs::write(&tool, "#!/bin/sh\nexit 99\n").unwrap();
|
||||
assert!(ide_host::ops::run::start(&f.host, "main", config.clone(), Arc::new(|_| {})).await.is_err());
|
||||
plugins.set_capability("execution", "process:execution-tool", false).unwrap();
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
assert!(ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.is_err());
|
||||
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
f.host.set_project_trust(&f.root, false, false).await.unwrap();
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
assert!(
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap_err().to_string().contains("trusted")
|
||||
);
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
f.host.close_project_detached("main");
|
||||
assert_eq!(plugins.run_targets(&f.root).len(), 1);
|
||||
f.host.close_project_detached("second");
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
plugins.shutdown();
|
||||
}
|
||||
@@ -75,6 +75,26 @@ impl TestWorkspace {
|
||||
}
|
||||
|
||||
impl PluginWorkspace for TestWorkspace {
|
||||
fn run_configuration(&self, _client: &str, plugin: &str, id: &str) -> Result<ide_run::RunConfig, HostError> {
|
||||
if !(plugin == "execution" && matches!(id, "detected" | "dynamic") || plugin == "api14" && id == "tests") {
|
||||
return Err(HostError::NotFound(id.into()));
|
||||
}
|
||||
Ok(ide_run::RunConfig {
|
||||
id: format!("plugin:{plugin}:{id}"),
|
||||
name: "Tests".into(),
|
||||
kind: "plugin".into(),
|
||||
command: "execution-tool".into(),
|
||||
args: vec!["test".into()],
|
||||
cwd: Some(self.root.clone().into()),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: true,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras { params: [("framework".into(), "plugin".into())].into(), ..Default::default() },
|
||||
})
|
||||
}
|
||||
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
@@ -285,6 +305,7 @@ fn fixture(limits: Limits) -> Fixture {
|
||||
impl Fixture {
|
||||
fn inv(&self) -> Invocation {
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(self.project.clone()),
|
||||
editor: None,
|
||||
@@ -5642,3 +5663,409 @@ fn view_secondary_requires_api_1_3_and_a_boolean_tool_window_placement() {
|
||||
assert!(err.message.contains(message), "{api}, {location}, {value}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_denies_new_privileges_and_requires_an_invoking_window() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("api14"), &Consent::default()).unwrap();
|
||||
for (command, arg, capability) in [
|
||||
("execute", "test.action", "ide.commands"),
|
||||
("execute", "plugin.other.run", "ide.commands"),
|
||||
("execute", "plugin.api14.other.run", "ide.commands"),
|
||||
("open-editors", "", "editor.read"),
|
||||
("open-editor", "a.txt", "project.read"),
|
||||
("search-text", "needle", "project.read"),
|
||||
("problems", "", "project.read"),
|
||||
("decorations", "a.txt", "project.read"),
|
||||
("clear-decorations", "", "project.read"),
|
||||
("file-decoration", "a.txt", "project.read"),
|
||||
("clear-file-decorations", "", "project.read"),
|
||||
("vcs-log", "", "vcs.read"),
|
||||
("vcs-blame", "a.txt", "vcs.read"),
|
||||
("vcs-diff", "", "vcs.read"),
|
||||
("ipc", "/tmp/api14-no-grant", "ipc:"),
|
||||
("fetch", "http://example.com/", "net.http:"),
|
||||
("secret", "value", "secrets"),
|
||||
("delete-secret", "", "secrets"),
|
||||
] {
|
||||
let error = f.run("api14", command, Some(arg)).unwrap_err();
|
||||
assert!(error.contains(capability) && error.contains("not granted"), "{error}");
|
||||
}
|
||||
for command in ["window-status", "remove-window-status", "execute", "oauth", "output"] {
|
||||
let error = f.host.run_command("api14", command, Some("value"), Invocation::default()).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput, "{command}: {error:?}");
|
||||
}
|
||||
let own = f.run("api14", "execute", Some("plugin.api14.execute")).unwrap_err();
|
||||
assert!(own.contains("recursive") && !own.contains("not granted"), "{own}");
|
||||
f.host.set_capability("api14", "secrets", true).unwrap();
|
||||
f.run("api14", "secret", Some(" raw value ")).unwrap();
|
||||
assert_eq!(f.host.declared_secret("api14", "token"), Some(" raw value ".into()));
|
||||
assert!(f.run("api14", "undeclared-secret", Some("value")).unwrap_err().contains("undeclared"));
|
||||
f.run("api14", "delete-secret", None).unwrap();
|
||||
assert_eq!(f.host.declared_secret("api14", "token"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_window_status_overrides_fall_back_and_are_withdrawn() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("api14"));
|
||||
f.run("api14", "global-status", Some("global")).unwrap();
|
||||
f.run("api14", "window-status", Some("main")).unwrap();
|
||||
let snapshot = f.host.status_snapshot("main");
|
||||
assert!(snapshot.iter().any(|i| i.window && i.item.text == "main"));
|
||||
assert!(!f.host.status_snapshot("other").iter().any(|i| i.window));
|
||||
f.run("api14", "global-status", Some("updated")).unwrap();
|
||||
f.run("api14", "remove-window-status", None).unwrap();
|
||||
assert_eq!(f.host.status_snapshot("main").len(), 1);
|
||||
assert_eq!(f.host.status_snapshot("main")[0].item.text, "updated");
|
||||
f.run("api14", "window-status", Some("override")).unwrap();
|
||||
f.host.forget_client("main");
|
||||
assert!(!f.host.status_snapshot("main").iter().any(|i| i.window));
|
||||
f.host.reopen_client("main");
|
||||
f.run("api14", "window-status", Some("again")).unwrap();
|
||||
f.host.set_enabled("api14", false).unwrap();
|
||||
assert!(f.host.status_snapshot("main").is_empty());
|
||||
assert!(f.take_events().iter().any(|(window, event)| window.as_deref() == Some("main")
|
||||
&& matches!(event, PluginEvent::WindowStatusItemChanged { item: None, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_command_requests_preserve_window_result_and_reject_cycles() {
|
||||
let f = Arc::new(fixture(Limits::default()));
|
||||
f.install(&sample("api14"));
|
||||
assert!(f.run("api14", "execute", Some("plugin.api14.execute")).unwrap_err().contains("recursive"));
|
||||
for answer in [CommandAnswer::Value(Some("result".into())), CommandAnswer::Value(None), CommandAnswer::NotFound] {
|
||||
f.take_events();
|
||||
let runner = f.clone();
|
||||
let worker = std::thread::spawn(move || runner.run("api14", "execute", Some("test.action")));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let request = loop {
|
||||
if let Some((window, PluginEvent::ExecuteCommand { request_id, arg, chain, .. })) =
|
||||
f.events.lock().iter().find(|(_, e)| matches!(e, PluginEvent::ExecuteCommand { .. })).cloned()
|
||||
{
|
||||
assert_eq!(window.as_deref(), Some("main"));
|
||||
assert_eq!(arg.as_deref(), Some("argument"));
|
||||
assert_eq!(chain, ["api14"]);
|
||||
break request_id;
|
||||
}
|
||||
assert!(Instant::now() < deadline);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert!(!f.host.answer_command("other", &request, answer.clone()));
|
||||
assert!(f.host.answer_command("main", &request, answer.clone()));
|
||||
let result = worker.join().unwrap();
|
||||
match answer {
|
||||
CommandAnswer::Value(value) => assert_eq!(result.unwrap(), value),
|
||||
CommandAnswer::NotFound => assert!(result.unwrap_err().contains("test.action")),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_settings_and_input_events_use_other_and_capture_the_editor() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("api14"));
|
||||
use ide_plugin::WorkbenchEvent as E;
|
||||
f.host.post_events(Some("main"), vec![E::SettingsChanged(Vec::new())]);
|
||||
f.workspace
|
||||
.settings
|
||||
.lock()
|
||||
.insert("api14".into(), serde_json::from_value(serde_json::json!({"enabled": true})).unwrap());
|
||||
let path = to_ipc(&f.project.join("src/notes.txt"));
|
||||
f.host.post_window_events(
|
||||
"main",
|
||||
vec![
|
||||
E::SettingsChanged(vec!["another-plugin-key".into()]),
|
||||
E::FileClosed(path.clone()),
|
||||
E::InputModeChanged(ide_plugin::InputMode {
|
||||
path: path.clone(),
|
||||
mode: "visual".into(),
|
||||
sub: Some("linewise".into()),
|
||||
}),
|
||||
],
|
||||
Some(PluginEditor {
|
||||
path: path.clone(),
|
||||
language: "text".into(),
|
||||
selections: vec![ide_core::Range::default()],
|
||||
}),
|
||||
);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(2));
|
||||
assert!(f.host.wait_events(Duration::from_secs(5)));
|
||||
let received: serde_json::Value = serde_json::from_str(&f.run("api14", "events", None).unwrap().unwrap()).unwrap();
|
||||
let events = received.as_array().unwrap();
|
||||
assert_eq!(events.len(), 3, "{received}");
|
||||
assert_eq!(events[0]["kind"], "settings-changed");
|
||||
assert_eq!(events[0]["data"], "[\"enabled\"]");
|
||||
assert_eq!(events[2]["kind"], "input-mode-changed");
|
||||
assert_eq!(events[2]["editor"][0], path);
|
||||
assert_eq!(events[2]["editor"][1][0], serde_json::json!([0, 0, 0, 0]));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_ipc_echo_checks_the_grant_in_the_wasm_binding() {
|
||||
use std::io::{Read, Write};
|
||||
let f = fixture(Limits::default());
|
||||
let pkg = f._tmp.path().join("api14-package");
|
||||
copy_dir(&sample("api14"), &pkg);
|
||||
let path = f._tmp.path().join("echo.sock");
|
||||
let manifest = pkg.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest)
|
||||
.unwrap()
|
||||
.replace("ipc:$TMPDIR/api14-*", &format!("ipc:{}", f._tmp.path().join("*.sock").display()));
|
||||
std::fs::write(manifest, text).unwrap();
|
||||
f.install(&pkg);
|
||||
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut bytes = [0; 4];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
stream.write_all(&bytes).unwrap();
|
||||
});
|
||||
assert_eq!(f.run("api14", "ipc", path.to_str()).unwrap().as_deref(), Some("echo"));
|
||||
worker.join().unwrap();
|
||||
f.host.set_capability("api14", &format!("ipc:{}", f._tmp.path().join("*.sock").display()), false).unwrap();
|
||||
assert!(f.run("api14", "ipc", path.to_str()).unwrap_err().contains("not granted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_oauth_wait_excludes_the_call_budget_and_closes_after_redirect() {
|
||||
use std::io::{Read, Write};
|
||||
let f = Arc::new(fixture(Limits { timeout: Duration::from_millis(200), ..Limits::default() }));
|
||||
f.install(&sample("api14"));
|
||||
let runner = f.clone();
|
||||
let worker = std::thread::spawn(move || runner.run("api14", "oauth", None));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let uri = loop {
|
||||
if let Some((_, PluginEvent::OutputChanged { text: Some(text), .. })) = f
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|(_, e)| matches!(e, PluginEvent::OutputChanged { channel, .. } if channel == "oauth"))
|
||||
.cloned()
|
||||
{
|
||||
break text;
|
||||
}
|
||||
assert!(Instant::now() < deadline);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert!(uri.starts_with("http://127.0.0.1:"));
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let authority = uri.strip_prefix("http://").unwrap().split('/').next().unwrap();
|
||||
let mut stream = std::net::TcpStream::connect(authority).unwrap();
|
||||
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=test HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response).unwrap();
|
||||
assert!(response.contains("You can close this tab"));
|
||||
assert_eq!(worker.join().unwrap().unwrap().as_deref(), Some("code=a%2Bb&state=test"));
|
||||
assert!(std::net::TcpStream::connect(authority).is_err());
|
||||
f.run("api14", "global-status", Some("still running")).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_executable_choices_are_pinned_per_project_and_reset_revalidates() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = fixture(Limits::default());
|
||||
let package = f._tmp.path().join("api14-process");
|
||||
copy_dir(&sample("api14"), &package);
|
||||
let manifest = package.join("plugin.toml");
|
||||
let text =
|
||||
std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", ");
|
||||
std::fs::write(manifest, text).unwrap();
|
||||
f.install(&package);
|
||||
let executable = |name: &str| {
|
||||
let path = f._tmp.path().join(format!("{name}-bin"));
|
||||
std::fs::write(&path, format!("#!/bin/sh\nprintf '{name}'\n")).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
path.canonicalize().unwrap()
|
||||
};
|
||||
let global = executable("global");
|
||||
let project = executable("project");
|
||||
f.host.choose_program("api14", "process:api14-tool", None, Some(&global)).unwrap();
|
||||
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&project)).unwrap();
|
||||
assert_eq!(f.host.program_for("api14", "api14-tool").unwrap(), global);
|
||||
assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).unwrap(), project);
|
||||
for command in ["process-run", "process-run-command", "process-start"] {
|
||||
assert_eq!(f.run("api14", command, None).unwrap().as_deref(), Some("project"));
|
||||
let invocation = Invocation { client: Some("global".into()), ..Invocation::default() };
|
||||
assert_eq!(f.host.run_command("api14", command, None, invocation).unwrap().as_deref(), Some("global"));
|
||||
}
|
||||
f.run("api14", "terminal", None).unwrap();
|
||||
assert!(
|
||||
f.take_events().iter().any(
|
||||
|(_, e)| matches!(e, PluginEvent::OpenTerminal { program, .. } if program.as_ref() == project.as_path())
|
||||
)
|
||||
);
|
||||
f.run("api14", "log", None).unwrap();
|
||||
assert!(
|
||||
f.take_events()
|
||||
.iter()
|
||||
.any(|(_, e)| matches!(e, PluginEvent::OpenLogTab { log, .. } if log.program == "api14-tool"))
|
||||
);
|
||||
let unrelated = f._tmp.path().join("unrelated");
|
||||
assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&unrelated)).unwrap(), global);
|
||||
std::fs::write(&project, "changed").unwrap();
|
||||
assert!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).is_err());
|
||||
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), None).unwrap();
|
||||
assert_eq!(f.host.program_for_project("api14", "api14-tool", Some(&f.project)).unwrap(), global);
|
||||
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&project)).unwrap();
|
||||
std::fs::write(&global, "changed").unwrap();
|
||||
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), None).unwrap();
|
||||
assert!(
|
||||
!f.host.info_of("api14").unwrap().capabilities.iter().find(|c| c.id == "process:api14-tool").unwrap().granted
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn execution_fixture() -> Arc<Fixture> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Arc::new(fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() }));
|
||||
f.install(&sample("execution"));
|
||||
std::fs::write(f.project.join("execution.marker"), "").unwrap();
|
||||
let executable = f._tmp.path().join("execution-bin");
|
||||
std::fs::write(&executable, "#!/bin/sh\nprintf '%s\n' \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
f.host.choose_program("execution", "process:execution-tool", Some(&f.project), Some(&executable)).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn execution_request(f: &Arc<Fixture>, command: &str) -> (String, WindowRequest) {
|
||||
let command = command.to_owned();
|
||||
let runner = f.clone();
|
||||
let worker = std::thread::spawn(move || runner.run("execution", &command, None));
|
||||
let mut request = None;
|
||||
assert!(eventually(|| {
|
||||
for (client, event) in f.take_events() {
|
||||
if let PluginEvent::WindowRequest { request_id, request: r, .. } = event {
|
||||
assert_eq!(client.as_deref(), Some("main"));
|
||||
request = Some((request_id, r));
|
||||
}
|
||||
}
|
||||
request.is_some()
|
||||
}));
|
||||
let (request_id, request) = request.unwrap();
|
||||
assert!(!f.host.answer_command("second", &request_id, CommandAnswer::Value(None)));
|
||||
if !request_id.is_empty() {
|
||||
assert!(f.host.answer_command("main", &request_id, CommandAnswer::Value(None)));
|
||||
}
|
||||
let id = worker.join().unwrap().unwrap().unwrap_or_default();
|
||||
(id, request)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_stage3_runs_and_terminals_deliver_exactly_one_exit_after_idle_reclamation() {
|
||||
let f = execution_fixture();
|
||||
let (run, request) = execution_request(&f, "run");
|
||||
assert!(matches!(request, WindowRequest::Run { config, .. } if config.args == ["test", "appended"]));
|
||||
assert!(f.run("execution", "debug", None).unwrap_err().contains("debugger"));
|
||||
let (terminal, _) = execution_request(&f, "terminal");
|
||||
assert_eq!(execution_request(&f, "terminal").0, terminal);
|
||||
assert!(f.run("execution", "write", None).unwrap_err().contains("running terminal"));
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
f.host.tick();
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
assert!(!f.host.finish_operation("second", &run, Some(2), false));
|
||||
assert!(f.host.finish_operation("main", &run, Some(2), true));
|
||||
assert!(!f.host.finish_operation("main", &run, Some(2), true));
|
||||
assert!(f.host.finish_operation("main", &terminal, None, true));
|
||||
assert!(!f.host.finish_operation("main", &terminal, Some(0), false));
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(eventually(|| f.host.wait_events(Duration::from_secs(1))));
|
||||
let events: serde_json::Value =
|
||||
serde_json::from_str(&f.run("execution", "events", None).unwrap().unwrap()).unwrap();
|
||||
let events = events.as_array().unwrap();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0]["kind"], "run-exited");
|
||||
let data: serde_json::Value = serde_json::from_str(events[0]["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(data["id"], run);
|
||||
assert_eq!(data["killed"], true);
|
||||
assert_eq!(events[1]["kind"], "terminal-exited");
|
||||
assert!(f.run("execution", "write", None).unwrap_err().contains("running terminal"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports() {
|
||||
let f = execution_fixture();
|
||||
f.host.refresh_run_configurations(&f.project);
|
||||
let configs = f.host.run_detection().detect(&f.project);
|
||||
assert!(configs.iter().any(|c| c.id == "plugin:execution:dynamic"
|
||||
&& c.is_test
|
||||
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")));
|
||||
assert_eq!(
|
||||
f.host.parse_test_results("main", "plugin:execution:dynamic", "batch".into()).unwrap().unwrap().len(),
|
||||
3
|
||||
);
|
||||
let (_, request) = execution_request(&f, "content");
|
||||
assert!(matches!(request, WindowRequest::ContentTab { tab } if tab.key == "config"));
|
||||
f.take_events();
|
||||
let action = ide_plugin::ContentTabAction {
|
||||
request_id: "ct-1".into(),
|
||||
key: "config".into(),
|
||||
action: "save".into(),
|
||||
content: "edited".into(),
|
||||
};
|
||||
assert!(f.host.content_tab_action("second", "execution", action.clone()).is_err());
|
||||
f.host.content_tab_action("main", "execution", action).unwrap();
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(eventually(|| {
|
||||
f.events.lock().iter().any(|(client, event)| client.as_deref() == Some("main") && matches!(event, PluginEvent::ContentTabReply { request_id, reply, .. } if request_id == "ct-1" && reply.saved == Some(true)))
|
||||
}));
|
||||
assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api14_fixture_exports_test_selection() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("api14"));
|
||||
assert_eq!(
|
||||
f.host.select_tests("main", "plugin:api14:tests", vec![vec!["Suite".into(), "Test".into()]]).unwrap(),
|
||||
Some(vec!["Suite::Test".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_test_selection_uses_owned_config_ids_and_name_paths() {
|
||||
let f = execution_fixture();
|
||||
f.host.refresh_run_configurations(&f.project);
|
||||
for id in ["dynamic", "detected"] {
|
||||
assert_eq!(
|
||||
f.host
|
||||
.select_tests(
|
||||
"main",
|
||||
&format!("plugin:execution:{id}"),
|
||||
vec![vec!["Suite".into(), "Test".into()], vec!["Other".into()]]
|
||||
)
|
||||
.unwrap(),
|
||||
Some(vec![id.into(), "Suite::Test".into(), "Other".into()])
|
||||
);
|
||||
}
|
||||
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec!["none".into()]]).unwrap(), None);
|
||||
assert_eq!(f.host.select_tests("main", "cargo:test", vec![vec!["Test".into()]]).unwrap(), None);
|
||||
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![]).unwrap(), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_test_selection_traps_and_timeouts_are_unsupported() {
|
||||
for failure in ["trap", "timeout"] {
|
||||
let f = execution_fixture();
|
||||
f.host.refresh_run_configurations(&f.project);
|
||||
let started = Instant::now();
|
||||
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec![failure.into()]]).unwrap(), None);
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn api14_stage3_terminal_calls_are_denied_without_process_grants() {
|
||||
let f = execution_fixture();
|
||||
f.host.set_capability("execution", "process:execution-tool", false).unwrap();
|
||||
assert!(f.run("execution", "terminal", None).unwrap_err().contains("not granted"));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ struct Client {
|
||||
events: Mutex<Vec<EventMessage>>,
|
||||
plugin_events: Mutex<Vec<PluginEventMessage>>,
|
||||
secret_requests: Mutex<Vec<SecretRequest>>,
|
||||
desktop: Option<Arc<PluginHost>>,
|
||||
}
|
||||
|
||||
impl Handler for Client {
|
||||
@@ -45,6 +46,19 @@ impl Handler for Client {
|
||||
self.secret_requests.lock().push(r);
|
||||
return Ok(serde_json::to_value(value).unwrap());
|
||||
}
|
||||
if let Some(desktop) = &self.desktop {
|
||||
use ide_host::plugins::remote_host::{AUTH_REQUEST, SECRET_WRITE};
|
||||
if method == AUTH_REQUEST {
|
||||
return Ok(
|
||||
serde_json::to_value(desktop.remote_auth("main", serde_json::from_value(params).unwrap())).unwrap()
|
||||
);
|
||||
}
|
||||
if method == SECRET_WRITE {
|
||||
return Ok(
|
||||
serde_json::to_value(desktop.remote_secret_write(serde_json::from_value(params).unwrap())).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
|
||||
}
|
||||
}
|
||||
@@ -207,11 +221,11 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
|
||||
})
|
||||
.await;
|
||||
|
||||
// Disabled on the desktop: gone from the server.
|
||||
// Disabled on the desktop: retained but stopped on the server.
|
||||
desktop.set_enabled("openidea.checkstyle", false).unwrap();
|
||||
let removed = sync(&peer, &desktop, &[]).await;
|
||||
assert_eq!(removed.removed, ["openidea.checkstyle"]);
|
||||
assert!(plugins.host().info_of("openidea.checkstyle").is_err());
|
||||
assert!(removed.removed.is_empty());
|
||||
assert_eq!(plugins.host().info_of("openidea.checkstyle").unwrap().status, PluginStatus::Disabled);
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
@@ -350,3 +364,515 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let desktop = Arc::new(
|
||||
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.unwrap(),
|
||||
);
|
||||
desktop.install_from_dir(&fixture("api14"), &Consent::all()).unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer_slot.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.into()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
let server = tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let client = Arc::new(Client { desktop: Some(desktop.clone()), ..Client::default() });
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
|
||||
sync(&peer, &desktop, &[]).await;
|
||||
let report = sync(&peer, &desktop, &["api14"]).await;
|
||||
assert!(report.failed.is_empty(), "{report:?}");
|
||||
let run = |command: &str, arg: Option<&str>| json!({ "pluginId": "api14", "commandId": command, "arg": arg, "editor": null });
|
||||
let remote_file = project.join("remote.txt");
|
||||
std::fs::write(&remote_file, "remote text").unwrap();
|
||||
invoke(&peer, "mcp_set_open_editors", json!({"paths": [remote_file]})).await.unwrap();
|
||||
invoke(&peer, "mcp_set_focused_editor", json!({"editor": {"path": remote_file, "selection": {"start": {"line": 0, "col": 0}, "end": {"line": 0, "col": 0}}, "selectedText": ""}, "focused": true})).await.unwrap();
|
||||
let editors = invoke(&peer, "plugin_run_command", run("open-editors", None)).await.unwrap();
|
||||
assert!(editors.as_str().unwrap().contains("remote.txt"));
|
||||
assert!(editors.as_str().unwrap().contains("active: true"));
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert_eq!(problems.as_str(), Some("[]"));
|
||||
let other_file = project.join("other.txt");
|
||||
std::fs::write(&other_file, "other text").unwrap();
|
||||
let outside_file = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside_file, "outside").unwrap();
|
||||
let problem = |path: &Path, message: &str| {
|
||||
json!({"path": path, "items": [{
|
||||
"range": {"start": {"line": 0, "col": 1}, "end": {"line": 0, "col": 3}},
|
||||
"severity": "warning", "message": message, "source": "remote-check", "code": null
|
||||
}]})
|
||||
};
|
||||
let files = json!([
|
||||
problem(&remote_file, "remote warning"),
|
||||
problem(&other_file, "other warning"),
|
||||
problem(&outside_file, "outside warning")
|
||||
]);
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(problems.contains("other warning"), "{problems}");
|
||||
assert!(!problems.contains("outside warning"), "{problems}");
|
||||
for path in ["remote.txt", remote_file.to_str().unwrap()] {
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(!problems.contains("other warning"), "{problems}");
|
||||
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
||||
}
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
|
||||
.await
|
||||
.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(!problems.as_str().unwrap().contains("remote warning"));
|
||||
assert!(problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(
|
||||
&peer,
|
||||
"mcp_report_problems",
|
||||
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(problems.as_str().unwrap().contains("replacement warning"));
|
||||
assert!(!problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
|
||||
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
|
||||
let sets = invoke(&peer, "plugin_decoration_sets", json!({})).await.unwrap();
|
||||
assert_eq!(sets["api14"][0]["root"], project.to_str().unwrap());
|
||||
until("forwarded editor and decoration events", 5, || {
|
||||
let events = client.plugin_events.lock();
|
||||
events.iter().any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::EditorOpen { .. }))
|
||||
&& events.iter().any(|m| matches!(m.event, PluginEvent::DecorationsChanged { .. }))
|
||||
})
|
||||
.await;
|
||||
invoke(&peer, "plugin_run_command", run("secret", Some("remote-token"))).await.unwrap();
|
||||
assert_eq!(desktop.declared_secret("api14", "token").as_deref(), Some("remote-token"));
|
||||
invoke(&peer, "plugin_run_command", run("delete-secret", None)).await.unwrap();
|
||||
assert_eq!(desktop.declared_secret("api14", "token"), None);
|
||||
invoke(&peer, "plugin_run_command", run("window-status", Some("remote"))).await.unwrap();
|
||||
let status = invoke(&peer, "plugin_status_items", json!({})).await.unwrap();
|
||||
assert!(status.as_array().unwrap().iter().any(|s| s["window"] == true && s["item"]["text"] == "remote"));
|
||||
let oauth_peer = peer.clone();
|
||||
let oauth = tokio::spawn(async move {
|
||||
invoke(
|
||||
&oauth_peer,
|
||||
"plugin_run_command",
|
||||
json!({ "pluginId": "api14", "commandId": "oauth", "arg": null, "editor": null }),
|
||||
)
|
||||
.await
|
||||
});
|
||||
until("remote OAuth URI", 5, || {
|
||||
client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|m| matches!(&m.event, PluginEvent::OutputChanged { channel, .. } if channel == "oauth"))
|
||||
})
|
||||
.await;
|
||||
let uri = client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|m| match &m.event {
|
||||
PluginEvent::OutputChanged { channel, text: Some(text), .. } if channel == "oauth" => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let authority = uri.strip_prefix("http://").unwrap().split('/').next().unwrap();
|
||||
let mut stream = tokio::net::TcpStream::connect(authority).await.unwrap();
|
||||
stream.write_all(b"GET /oauth/callback?code=desktop%2Bcode HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap();
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response).await.unwrap();
|
||||
assert!(response.contains("You can close this tab"));
|
||||
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
|
||||
let plugin_host = plugins.host().clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
plugin_host
|
||||
.run_command(
|
||||
"api14",
|
||||
"problems",
|
||||
None,
|
||||
ide_plugin::Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(project),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.as_deref(), Some("[]"));
|
||||
plugins.host().shutdown();
|
||||
desktop.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution_host() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = tmp.path().join("bin");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let executable = bin.join("execution-tool");
|
||||
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path_var = {
|
||||
let bin = bin.clone();
|
||||
Arc::new(move || Some(bin.clone().into_os_string()))
|
||||
};
|
||||
let desktop =
|
||||
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.unwrap();
|
||||
desktop.set_program_path(path_var.clone());
|
||||
desktop.install_from_dir(&fixture("execution"), &Consent::all()).unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
std::fs::write(project.join("execution.marker"), "").unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer_slot.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.into()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
|
||||
plugins.host().set_program_path(path_var);
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, dispatcher, peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
|
||||
sync(&peer, &desktop, &[]).await;
|
||||
assert!(sync(&peer, &desktop, &["execution"]).await.failed.is_empty());
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"execution", "commandId":"target", "arg":null, "editor":null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap();
|
||||
assert_eq!(targets.as_array().unwrap().len(), 1);
|
||||
assert_eq!(targets[0]["pluginId"], "execution");
|
||||
assert!(desktop.run_targets(&project).is_empty());
|
||||
let id = targets[0]["id"].as_str().unwrap();
|
||||
let settings = invoke(&peer, "plugin_answer_run_target", json!({"id":id, "useTarget":false})).await.unwrap();
|
||||
assert_eq!(settings["run"]["defaultTarget"], "");
|
||||
let config = json!({"id":"wrapped-php", "name":"Wrapped PHP", "kind":"php", "command":"php", "args":["x.php", "literal ; $(touch bad)"], "env":{}, "detected":false, "isTest":false, "target":id});
|
||||
invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":38}})).await.unwrap();
|
||||
until("remote wrapped run", 5, || project.join("wrapped.txt").exists()).await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(project.join("wrapped.txt")).unwrap(),
|
||||
"<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n"
|
||||
);
|
||||
assert!(!project.join("bad").exists());
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"execution", "commandId":"untarget", "arg":null, "editor":null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(), json!([]));
|
||||
client.plugin_events.lock().clear();
|
||||
client.events.lock().clear();
|
||||
let runner = peer.clone();
|
||||
let run = tokio::spawn(async move {
|
||||
invoke(
|
||||
&runner,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "terminal", "arg": null, "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
until("terminal request", 5, || {
|
||||
client.plugin_events.lock().iter().any(|m| matches!(m.event, PluginEvent::WindowRequest { .. }))
|
||||
})
|
||||
.await;
|
||||
let (request_id, operation) = client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|m| match &m.event {
|
||||
PluginEvent::WindowRequest {
|
||||
request_id,
|
||||
request: ide_host::plugins::WindowRequest::Terminal { operation, .. },
|
||||
..
|
||||
} => {
|
||||
assert_eq!(m.window.as_deref(), Some("main"));
|
||||
Some((request_id.clone(), operation.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let terminal = invoke(
|
||||
&peer,
|
||||
"plugin_terminal_session_spawn",
|
||||
json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 35}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(terminal["id"].as_u64().is_some());
|
||||
assert!(
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_terminal_session_spawn",
|
||||
json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 36}})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_answer_command",
|
||||
json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(run.await.unwrap(), operation);
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "write", "arg": "finished\n", "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
until("PTY exit", 5, || client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::TerminalExited { .. })))
|
||||
.await;
|
||||
plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3));
|
||||
plugins.host().wait_events(Duration::from_secs(5));
|
||||
let received = invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap();
|
||||
assert_eq!(received.as_array().unwrap().len(), 1);
|
||||
assert_eq!(received[0]["kind"], "terminal-exited");
|
||||
let data: Value = serde_json::from_str(received[0]["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(data["id"], operation);
|
||||
assert_eq!(data["status"], 0);
|
||||
assert_eq!(data["closed"], false);
|
||||
|
||||
client.plugin_events.lock().clear();
|
||||
client.events.lock().clear();
|
||||
let runner = peer.clone();
|
||||
let run = tokio::spawn(async move {
|
||||
invoke(
|
||||
&runner,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "run", "arg": null, "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
until("run request", 5, || {
|
||||
client.plugin_events.lock().iter().any(|m| {
|
||||
matches!(&m.event, PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::Run { .. }, .. })
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let (request_id, operation, config) = client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|m| match &m.event {
|
||||
PluginEvent::WindowRequest {
|
||||
request_id,
|
||||
request: ide_host::plugins::WindowRequest::Run { operation, config, .. },
|
||||
..
|
||||
} => Some((request_id.clone(), operation.clone(), config.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let info = invoke(&peer, "run_start", json!({"config": config, "onOutput": {"$channel": 37}})).await.unwrap();
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_answer_command",
|
||||
json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(run.await.unwrap(), operation);
|
||||
invoke(&peer, "run_stdin", json!({"id": info["id"], "data": "finished\n"})).await.unwrap();
|
||||
until("run exit", 5, || client.events.lock().iter().any(|m| matches!(&m.event, IdeEvent::RunExited { .. }))).await;
|
||||
plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3));
|
||||
plugins.host().wait_events(Duration::from_secs(5));
|
||||
let received = invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap();
|
||||
assert_eq!(received.as_array().unwrap().len(), 2);
|
||||
assert_eq!(received[1]["kind"], "run-exited");
|
||||
let data: Value = serde_json::from_str(received[1]["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(data["id"], operation);
|
||||
assert_eq!(data["status"], 0);
|
||||
assert_eq!(data["killed"], false);
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn api14_database_sources_credentials_and_disable_lifetime_run_on_the_server() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let id = "test.data-tools";
|
||||
let desktop =
|
||||
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.unwrap();
|
||||
desktop.install_from_dir(&fixture("data-tools"), &Consent::all()).unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
std::fs::write(project.join("data.sqlite"), "").unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
let slot = Arc::new(OnceLock::new());
|
||||
let sink = slot.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| rpc::event_sink(sink.clone(), Some(w.into()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let plugins = RemotePlugins::start(&base.join("data"), &host, slot.clone()).unwrap();
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
plugins.register(&mut dispatcher);
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host.clone(), dispatcher, slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
let _: Value = peer.request("initialize", json!({"version":rpc::PROTOCOL_VERSION}), None).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path":project})).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({"path":project,"trusted":true,"folder":false})).await.unwrap();
|
||||
assert!(sync(&peer, &desktop, &[id]).await.failed.is_empty());
|
||||
let source =
|
||||
json!({"id":"local","name":"Remote local","driver":"sqlite","database":"data.sqlite","options":[]}).to_string();
|
||||
invoke(&peer, "plugin_run_command", json!({"pluginId":id,"commandId":"upsert","arg":source,"editor":null}))
|
||||
.await
|
||||
.unwrap();
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":id,"commandId":"credential","arg":"server-only-secret","editor":null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let sources = invoke(&peer, "db_sources", json!({})).await.unwrap();
|
||||
assert_eq!(sources[0]["pluginOwner"], id);
|
||||
let internal = sources[0]["id"].as_str().unwrap();
|
||||
let store = ide_db::secrets::SecretStore::new(base.join("data/db-passwords.json"), &project);
|
||||
assert_eq!(store.get(internal).as_deref(), Some("server-only-secret"));
|
||||
until("database source change", 5, || {
|
||||
client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::DbSourcesChanged))
|
||||
})
|
||||
.await;
|
||||
desktop.set_enabled(id, false).unwrap();
|
||||
let report = sync(&peer, &desktop, &[]).await;
|
||||
assert!(report.removed.is_empty());
|
||||
assert!(!invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty());
|
||||
desktop.set_enabled(id, true).unwrap();
|
||||
assert!(sync(&peer, &desktop, &[]).await.failed.is_empty());
|
||||
let runner = peer.clone();
|
||||
let connection = tokio::spawn(async move {
|
||||
invoke(
|
||||
&runner,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"test.data-tools","commandId":"connect","arg":null,"editor":null}),
|
||||
)
|
||||
.await
|
||||
});
|
||||
until("database connect request", 5, || {
|
||||
client.plugin_events.lock().iter().any(|m| {
|
||||
matches!(
|
||||
m.event,
|
||||
PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::DatabaseConnect { .. }, .. }
|
||||
)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let request = client
|
||||
.plugin_events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|m| match &m.event {
|
||||
PluginEvent::WindowRequest {
|
||||
request_id,
|
||||
request: ide_host::plugins::WindowRequest::DatabaseConnect { .. },
|
||||
..
|
||||
} => Some(request_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
invoke(&peer, "db_connect", json!({"id":internal})).await.unwrap();
|
||||
invoke(&peer, "plugin_answer_command", json!({"requestId":request,"answer":{"kind":"value","value":null}}))
|
||||
.await
|
||||
.unwrap();
|
||||
connection.await.unwrap().unwrap();
|
||||
assert!(project.join("data.sqlite").exists());
|
||||
desktop.uninstall(id).unwrap();
|
||||
assert_eq!(sync(&peer, &desktop, &[]).await.removed, [id]);
|
||||
assert!(invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty());
|
||||
assert!(store.get(internal).is_none());
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
@@ -26,6 +26,11 @@ pub fn with_proxy(builder: reqwest::ClientBuilder, proxy: &ProxySettings) -> req
|
||||
|
||||
/// A client for `proxy`.
|
||||
pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
|
||||
build_with_redirect(proxy, reqwest::redirect::Policy::default())
|
||||
}
|
||||
|
||||
/// A client with an explicit redirect policy, for callers enforcing network grants.
|
||||
pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult<reqwest::Client> {
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
@@ -44,6 +49,7 @@ pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
|
||||
];
|
||||
let builder = reqwest::Client::builder()
|
||||
.tls_backend_preconfigured(tls)
|
||||
.redirect(redirect)
|
||||
.resolve_to_addrs("localhost", &loopback)
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-extension.workspace = true
|
||||
blake3.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -16,6 +17,7 @@ thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
semver = "1"
|
||||
url = "2"
|
||||
wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation", "async"] }
|
||||
wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] }
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Runs, keyed terminals and plugin-owned content editors (API 1.4).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// ContentAction data exchanged with the IDE.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentAction {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub icon: Option<String>,
|
||||
pub primary: bool,
|
||||
pub save: bool,
|
||||
}
|
||||
|
||||
/// ContentTab data exchanged with the IDE.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentTab {
|
||||
pub key: String,
|
||||
pub title: String,
|
||||
pub file_name: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub content: String,
|
||||
pub read_only: bool,
|
||||
pub actions: Vec<ContentAction>,
|
||||
}
|
||||
|
||||
/// ContentDiff data exchanged with the IDE.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentDiff {
|
||||
pub left: String,
|
||||
pub right: String,
|
||||
pub left_label: Option<String>,
|
||||
pub right_label: Option<String>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// ContentTabReply data exchanged with the IDE.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentTabReply {
|
||||
pub content: Option<String>,
|
||||
pub saved: Option<bool>,
|
||||
pub read_only: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub diff: Option<ContentDiff>,
|
||||
}
|
||||
|
||||
/// PluginRunConfiguration data exchanged with the IDE.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRunConfiguration {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: Option<String>,
|
||||
pub test: bool,
|
||||
pub framework: Option<String>,
|
||||
}
|
||||
|
||||
/// RunExited data exchanged with the IDE.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunExited {
|
||||
pub id: String,
|
||||
pub config: String,
|
||||
pub status: Option<i32>,
|
||||
pub killed: bool,
|
||||
}
|
||||
|
||||
/// TerminalExited data exchanged with the IDE.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TerminalExited {
|
||||
pub id: String,
|
||||
pub key: String,
|
||||
pub status: Option<i32>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// ContentTabAction data exchanged with the IDE.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentTabAction {
|
||||
pub request_id: String,
|
||||
pub key: String,
|
||||
pub action: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Incremental test tree data.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TestStart {
|
||||
pub id: String,
|
||||
pub parent: Option<String>,
|
||||
pub name: String,
|
||||
pub location: Option<crate::Location>,
|
||||
}
|
||||
|
||||
/// Incremental test tree data.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TestPassed {
|
||||
pub id: String,
|
||||
pub duration_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Incremental test tree data.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TestFailed {
|
||||
pub id: String,
|
||||
pub duration_ms: Option<u32>,
|
||||
pub message: String,
|
||||
pub expected: Option<String>,
|
||||
pub actual: Option<String>,
|
||||
pub location: Option<crate::Location>,
|
||||
}
|
||||
|
||||
/// Incremental test tree data.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TestSkipped {
|
||||
pub id: String,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Incremental test tree data.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TestOutput {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A test update, carried by the optional test-results export.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginTestEvent {
|
||||
SuiteStarted(TestStart),
|
||||
TestStarted(TestStart),
|
||||
Passed(TestPassed),
|
||||
Failed(TestFailed),
|
||||
Skipped(TestSkipped),
|
||||
Output(TestOutput),
|
||||
Other { kind: String, data: String },
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Optional API 1.4 language features and embedded language ranges.
|
||||
use ide_core::Range;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
/// A parameter label, as text or UTF-16 offsets within the signature.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub enum ParameterLabel {
|
||||
Text(String),
|
||||
Offsets((u32, u32)),
|
||||
}
|
||||
/// FormatOptions data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormatOptions {
|
||||
pub tab_size: u32,
|
||||
pub insert_spaces: bool,
|
||||
}
|
||||
/// Parameter data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Parameter {
|
||||
pub label: ParameterLabel,
|
||||
pub documentation: Option<String>,
|
||||
}
|
||||
/// Signature data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Signature {
|
||||
pub label: String,
|
||||
pub documentation: Option<String>,
|
||||
pub parameters: Vec<Parameter>,
|
||||
}
|
||||
/// SignatureHelp data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SignatureHelp {
|
||||
pub signatures: Vec<Signature>,
|
||||
pub active_signature: u32,
|
||||
pub active_parameter: u32,
|
||||
}
|
||||
/// Highlight data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Highlight {
|
||||
pub range: Range,
|
||||
pub kind: String,
|
||||
}
|
||||
/// DocumentLink data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentLink {
|
||||
pub range: Range,
|
||||
pub target: String,
|
||||
}
|
||||
/// CodeLens data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeLens {
|
||||
pub range: Range,
|
||||
pub title: String,
|
||||
pub command: String,
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
/// FoldRange data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FoldRange {
|
||||
pub start_line: u32,
|
||||
pub end_line: u32,
|
||||
pub kind: String,
|
||||
}
|
||||
/// ColorInfo data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColorInfo {
|
||||
pub range: Range,
|
||||
pub red: f32,
|
||||
pub green: f32,
|
||||
pub blue: f32,
|
||||
pub alpha: f32,
|
||||
}
|
||||
/// DocumentSymbol data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentSymbol {
|
||||
pub name: String,
|
||||
pub detail: String,
|
||||
pub kind: String,
|
||||
pub range: Range,
|
||||
pub selection_range: Range,
|
||||
pub parent: Option<u32>,
|
||||
}
|
||||
/// GutterAction data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GutterAction {
|
||||
pub line: u32,
|
||||
pub icon: Option<String>,
|
||||
pub tooltip: String,
|
||||
pub command: String,
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
/// Injection data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Injection {
|
||||
pub range: Range,
|
||||
pub language: String,
|
||||
}
|
||||
@@ -23,3 +23,10 @@ pub use runtime::{
|
||||
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;
|
||||
|
||||
mod execution;
|
||||
pub use execution::*;
|
||||
|
||||
pub use ide_core::remote_settings::PluginRunTarget;
|
||||
|
||||
pub mod language_features;
|
||||
@@ -16,6 +16,8 @@ use crate::types::{
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
|
||||
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::injections_bindings::exports::openidea::plugin::injections as wit_inj;
|
||||
use crate::wit_host::language_features_bindings::exports::openidea::plugin::language_features as wit_lf;
|
||||
use crate::wit_host::{
|
||||
bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings,
|
||||
indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
|
||||
@@ -33,6 +35,8 @@ use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDea
|
||||
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
|
||||
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
use crate::wit_host::run_configurations_bindings::exports::openidea::plugin::run_configurations as wit_configs;
|
||||
use crate::wit_host::test_results_bindings::exports::openidea::plugin::test_results as wit_tests;
|
||||
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;
|
||||
@@ -50,7 +54,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.3";
|
||||
pub const API_VERSION: &str = "1.4";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -62,6 +66,190 @@ 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 {
|
||||
/// Creates/replaces this plugin's non-secret project connection settings.
|
||||
fn database_upsert(&self, root: &Path, source: crate::PluginDataSource) -> Result<(), HostError> {
|
||||
let _ = (root, source);
|
||||
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
|
||||
}
|
||||
/// Removes only an owned data source and its credentials.
|
||||
fn database_remove(&self, root: &Path, id: &str) -> Result<(), HostError> {
|
||||
let _ = (root, id);
|
||||
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
|
||||
}
|
||||
/// Private ids of only this plugin's sources.
|
||||
fn database_sources(&self, root: &Path) -> Result<Vec<String>, HostError> {
|
||||
let _ = root;
|
||||
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
|
||||
}
|
||||
/// Writes a password to the Database credential store, without a read path.
|
||||
fn database_credential(&self, root: &Path, id: &str, password: String) -> Result<(), HostError> {
|
||||
let _ = (root, id, password);
|
||||
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
|
||||
}
|
||||
/// Requests normal connection/introspection in the invoking project's window.
|
||||
fn database_connect(
|
||||
&self,
|
||||
root: &Path,
|
||||
client: &str,
|
||||
id: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let _ = (root, client, id, cancel, timeout);
|
||||
Err(HostError::Invalid("Database subsystem is unavailable on this host".into()))
|
||||
}
|
||||
/// Stdio argv only; no URL or bearer token.
|
||||
fn mcp_stdio_command(&self, client: &str) -> Option<Vec<String>> {
|
||||
let _ = client;
|
||||
None
|
||||
}
|
||||
|
||||
/// Registers a project-scoped wrapper after validating its process grant and pin.
|
||||
fn register_run_target(&self, root: &Path, target: crate::PluginRunTarget) -> Result<(), HostError> {
|
||||
let _ = (root, target);
|
||||
Err(HostError::Failed("run targets unavailable".into()))
|
||||
}
|
||||
/// Withdraws this plugin's target, preserving the user's remembered prompt answer.
|
||||
fn unregister_run_target(&self, root: &Path, id: &str) {
|
||||
let _ = (root, id);
|
||||
}
|
||||
|
||||
/// Starts one of this plugin's configurations in a window.
|
||||
fn start_run(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
args: Vec<String>,
|
||||
debug: bool,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<String, HostError> {
|
||||
let _ = (client, config, args, debug, cancel, timeout);
|
||||
Err(HostError::Failed("runs unavailable".into()))
|
||||
}
|
||||
/// Opens/reuses a keyed terminal, retained across idle instance reclamation.
|
||||
fn open_terminal_session(
|
||||
&self,
|
||||
client: &str,
|
||||
key: String,
|
||||
title: String,
|
||||
cmd: ProcessCommand,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<String, HostError> {
|
||||
let _ = (client, key, title, cmd, cancel, timeout);
|
||||
Err(HostError::Failed("terminals unavailable".into()))
|
||||
}
|
||||
/// Writes only to an owned, live terminal.
|
||||
fn write_terminal(
|
||||
&self,
|
||||
client: &str,
|
||||
key: &str,
|
||||
text: String,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let _ = (client, key, text, cancel, timeout);
|
||||
Err(HostError::Failed("terminals unavailable".into()))
|
||||
}
|
||||
/// Opens a content tab through the shared editor implementation.
|
||||
fn open_content_tab(
|
||||
&self,
|
||||
client: &str,
|
||||
tab: crate::ContentTab,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let _ = (client, tab, cancel, timeout);
|
||||
Err(HostError::Failed("content tabs unavailable".into()))
|
||||
}
|
||||
/// Answers a content action in the invoking window.
|
||||
fn reply_content_tab(&self, client: &str, request: &str, reply: crate::ContentTabReply) -> Result<(), HostError> {
|
||||
let _ = (client, request, reply);
|
||||
Err(HostError::Failed("content tabs unavailable".into()))
|
||||
}
|
||||
/// The invoking window's editors.
|
||||
fn open_editors(&self, client: &str) -> Result<Vec<crate::OpenEditor>, HostError> {
|
||||
let _ = client;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
/// Opens a validated file and optionally selects a range.
|
||||
fn editor_open(&self, client: &str, path: &Path, selection: Option<ide_core::Range>) -> Result<(), HostError> {
|
||||
let _ = (client, path, selection);
|
||||
Err(HostError::Failed("editor unavailable".into()))
|
||||
}
|
||||
/// Changes the selections, or reveals a range, only in an editor of this window.
|
||||
fn editor_ranges(
|
||||
&self,
|
||||
client: &str,
|
||||
path: &Path,
|
||||
ranges: Vec<ide_core::Range>,
|
||||
reveal: bool,
|
||||
) -> Result<(), HostError> {
|
||||
let _ = (client, path, ranges, reveal);
|
||||
Err(HostError::Failed("editor unavailable".into()))
|
||||
}
|
||||
/// Find in Files with the project's document overlay.
|
||||
fn search_text(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
root: &Path,
|
||||
query: crate::TextQuery,
|
||||
limit: u32,
|
||||
cancel: &CancellationToken,
|
||||
deadline: Instant,
|
||||
) -> Result<crate::TextSearch, HostError> {
|
||||
let _ = (client, root, query, limit, cancel, deadline);
|
||||
Err(HostError::Failed("search unavailable".into()))
|
||||
}
|
||||
/// Window-reported Problems view snapshot.
|
||||
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<crate::FileProblems>, HostError> {
|
||||
let _ = (client, path);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
/// Replace or clear an editor decoration set.
|
||||
fn decorations(&self, path: Option<&Path>, set: &str, items: Vec<crate::Decoration>) -> Result<(), HostError> {
|
||||
let _ = (path, set, items);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Replace or clear a tree decoration set.
|
||||
fn file_decorations(
|
||||
&self,
|
||||
path: Option<&Path>,
|
||||
set: &str,
|
||||
item: Option<crate::FileDecoration>,
|
||||
) -> Result<(), HostError> {
|
||||
let _ = (path, set, item);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Trusted repository history.
|
||||
fn vcs_log(
|
||||
&self,
|
||||
root: &Path,
|
||||
path: Option<&str>,
|
||||
limit: u32,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Vec<crate::CommitInfo>, HostError> {
|
||||
let _ = (root, path, limit, cancel);
|
||||
Err(HostError::Failed("git unavailable".into()))
|
||||
}
|
||||
/// Trusted repository attribution, including unsaved documents.
|
||||
fn vcs_blame(&self, client: Option<&str>, root: &Path, path: &str) -> Result<Vec<crate::BlameLine>, HostError> {
|
||||
let _ = (client, root, path);
|
||||
Err(HostError::Failed("git unavailable".into()))
|
||||
}
|
||||
/// Trusted repository working-tree patch, bounded to 4 MiB.
|
||||
fn vcs_diff(
|
||||
&self,
|
||||
root: &Path,
|
||||
path: Option<&str>,
|
||||
against: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let _ = (root, path, against, cancel);
|
||||
Err(HostError::Failed("git unavailable".into()))
|
||||
}
|
||||
|
||||
/// Shows a notification; `actions` name commands the plugin declares.
|
||||
fn notify(
|
||||
&self,
|
||||
@@ -101,8 +289,45 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (client, view);
|
||||
Ok(())
|
||||
}
|
||||
/// Whether this stable id is a command declared by this plugin.
|
||||
fn owns_command(&self, id: &str) -> bool {
|
||||
let _ = id;
|
||||
false
|
||||
}
|
||||
/// Executes a stable command in the invoking window and waits for its result.
|
||||
fn execute_command(
|
||||
&self,
|
||||
invocation: &Invocation,
|
||||
id: &str,
|
||||
arg: Option<String>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let _ = (invocation, id, arg, cancel, timeout);
|
||||
Err(HostError::NotFound("command".into()))
|
||||
}
|
||||
/// Appends text, or clears (`None`), in a plugin-owned output channel.
|
||||
fn output_write(&self, channel: &str, text: Option<&str>) -> Result<(), HostError> {
|
||||
let _ = (channel, text);
|
||||
Err(HostError::Failed("output unavailable".into()))
|
||||
}
|
||||
/// Reveals an output channel in the invoking window.
|
||||
fn output_reveal(&self, client: &str, channel: &str) -> Result<(), HostError> {
|
||||
let _ = (client, channel);
|
||||
Err(HostError::Failed("output unavailable".into()))
|
||||
}
|
||||
/// Checks manifest minimum API requirements for new declarations or subscriptions.
|
||||
fn require_api(&self, what: &str, minor: u64) -> Result<(), HostError> {
|
||||
let _ = (what, minor);
|
||||
Ok(())
|
||||
}
|
||||
/// Sets (`Some`) or removes the plugin's status bar item `id`.
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>);
|
||||
/// Sets or removes the status override in the invoking window.
|
||||
fn window_status_item(&self, client: &str, id: &str, item: Option<StatusItem>) -> Result<(), HostError> {
|
||||
let _ = (client, id, item);
|
||||
Err(HostError::Invalid("window status unavailable".into()))
|
||||
}
|
||||
/// Sets (`Some`, validated JSON text) or removes the plugin's context key,
|
||||
/// for window `client` (every window if `None`).
|
||||
fn set_context(&self, client: Option<&str>, key: &str, value: Option<String>) -> Result<(), HostError> {
|
||||
@@ -181,6 +406,62 @@ pub trait HostApi: Send + Sync {
|
||||
/// Performs `request` (its host is granted), filling in secret placeholders.
|
||||
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
|
||||
fn secret_is_set(&self, name: &str) -> bool;
|
||||
|
||||
/// Stores or deletes a secret declared by this plugin; never returns its value.
|
||||
fn secret_set(&self, name: &str, value: Option<&str>) -> Result<(), HostError> {
|
||||
let _ = (name, value);
|
||||
Err(HostError::NotFound("secret".into()))
|
||||
}
|
||||
/// Creates a desktop loopback listener and returns its handle and redirect URI.
|
||||
fn begin_loopback(&self, client: &str, path: &str) -> Result<(u64, String), HostError> {
|
||||
let _ = (client, path);
|
||||
Err(HostError::Failed("loopback unavailable".into()))
|
||||
}
|
||||
/// Awaits a browser redirect, excluded from the caller's execution budget.
|
||||
fn wait_loopback(
|
||||
&self,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let _ = (id, timeout, cancel);
|
||||
Err(HostError::NotFound("loopback".into()))
|
||||
}
|
||||
/// Releases a desktop listener when its resource or plugin stops.
|
||||
fn close_loopback(&self, id: u64) {
|
||||
let _ = id;
|
||||
}
|
||||
/// Connects to an approved host-local socket; owned by this plugin instance.
|
||||
fn ipc_connect(&self, name: &str, cancel: &CancellationToken, timeout: Duration) -> Result<u64, HostError> {
|
||||
let _ = (name, cancel, timeout);
|
||||
Err(HostError::Failed("IPC unavailable".into()))
|
||||
}
|
||||
/// Writes to an owned IPC connection.
|
||||
fn ipc_write(
|
||||
&self,
|
||||
id: u64,
|
||||
bytes: Vec<u8>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let _ = (id, bytes, cancel, timeout);
|
||||
Err(HostError::NotFound("connection".into()))
|
||||
}
|
||||
/// Reads from an owned IPC connection, empty on timeout or EOF.
|
||||
fn ipc_read(
|
||||
&self,
|
||||
id: u64,
|
||||
max: usize,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Vec<u8>, HostError> {
|
||||
let _ = (id, max, timeout, cancel);
|
||||
Err(HostError::NotFound("connection".into()))
|
||||
}
|
||||
/// Closes an owned IPC connection, including when its resource is dropped.
|
||||
fn ipc_close(&self, id: u64) {
|
||||
let _ = id;
|
||||
}
|
||||
/// Runs the approved file of `process:<program>` with `cmd.env` added to
|
||||
/// the inherited environment, and waits.
|
||||
fn run_process(
|
||||
@@ -631,6 +912,8 @@ pub struct Runtime {
|
||||
|
||||
/// What the component exports: the `plugin` world, plus optional interfaces.
|
||||
struct Exports {
|
||||
language_features: Option<wit_lf::Guest>,
|
||||
injections: Option<wit_inj::Guest>,
|
||||
plugin: bindings::Plugin,
|
||||
syntax: Option<wit_syntax::Guest>,
|
||||
providers: Option<wit_prov::Guest>,
|
||||
@@ -644,6 +927,9 @@ struct Exports {
|
||||
commit_checks: Option<wit_cc::Guest>,
|
||||
inlay: Option<wit_inlay::Guest>,
|
||||
endpoints: Option<wit_endpoints::Guest>,
|
||||
run_configurations: Option<wit_configs::Guest>,
|
||||
test_results: Option<wit_tests::Guest>,
|
||||
mcp: Option<crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::Guest>,
|
||||
}
|
||||
|
||||
/// Most results one syntax call may return (more are dropped).
|
||||
@@ -653,6 +939,30 @@ const MAX_BATCH: usize = 1000;
|
||||
|
||||
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
match e {
|
||||
WorkbenchEvent::RunExited(value) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "run-exited".into(),
|
||||
data: serde_json::to_string(&value).unwrap(),
|
||||
}),
|
||||
WorkbenchEvent::TerminalExited(value) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "terminal-exited".into(),
|
||||
data: serde_json::to_string(&value).unwrap(),
|
||||
}),
|
||||
WorkbenchEvent::ContentTabAction(value) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "content-tab-action".into(),
|
||||
data: serde_json::to_string(&value).unwrap(),
|
||||
}),
|
||||
WorkbenchEvent::FileClosed(p) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "file-closed".into(),
|
||||
data: serde_json::to_string(&p).expect("path serializes"),
|
||||
}),
|
||||
WorkbenchEvent::SettingsChanged(keys) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "settings-changed".into(),
|
||||
data: serde_json::to_string(&keys).expect("keys serialize"),
|
||||
}),
|
||||
WorkbenchEvent::InputModeChanged(mode) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "input-mode-changed".into(),
|
||||
data: serde_json::to_string(&mode).expect("mode serializes"),
|
||||
}),
|
||||
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
|
||||
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
|
||||
WorkbenchEvent::FilesChanged(batch) => wit_events::Event::Other(wit_types::Extension {
|
||||
@@ -737,7 +1047,19 @@ impl Runtime {
|
||||
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 configs_indices = wit_configs::GuestIndices::new(&pre).ok();
|
||||
let tests_indices = wit_tests::GuestIndices::new(&pre).ok();
|
||||
let lf_indices = wit_lf::GuestIndices::new(&pre).ok();
|
||||
let inj_indices = wit_inj::GuestIndices::new(&pre).ok();
|
||||
let mcp_indices =
|
||||
crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::GuestIndices::new(&pre).ok();
|
||||
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
|
||||
let mcp = mcp_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let run_configurations =
|
||||
configs_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let test_results = tests_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let language_features = lf_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let injections = inj_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
|
||||
let syntax = match syntax_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
@@ -790,6 +1112,8 @@ impl Runtime {
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports {
|
||||
language_features,
|
||||
injections,
|
||||
plugin,
|
||||
syntax,
|
||||
providers,
|
||||
@@ -802,6 +1126,9 @@ impl Runtime {
|
||||
commit_checks,
|
||||
inlay,
|
||||
endpoints,
|
||||
run_configurations,
|
||||
test_results,
|
||||
mcp,
|
||||
file_actions,
|
||||
},
|
||||
limits: setup.limits,
|
||||
@@ -809,6 +1136,71 @@ impl Runtime {
|
||||
})
|
||||
}
|
||||
|
||||
/// Complete configurations from the optional API 1.4 export.
|
||||
pub fn run_configurations(
|
||||
&mut self,
|
||||
root: String,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::PluginRunConfiguration>, CallError> {
|
||||
if self.exports.run_configurations.is_none() {
|
||||
return Err(CallError::missing_export("run-configurations"));
|
||||
}
|
||||
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
|
||||
let items = self.call(invocation, timeout, |e, store| {
|
||||
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
|
||||
})?;
|
||||
if items.len() > 1000 {
|
||||
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
|
||||
}
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|c| crate::PluginRunConfiguration {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
program: c.program,
|
||||
args: c.args,
|
||||
cwd: c.cwd,
|
||||
test: c.test,
|
||||
framework: c.framework,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
/// Selects tests under the same budget as the test parser.
|
||||
pub fn select_tests(
|
||||
&mut self,
|
||||
config: String,
|
||||
tests: Vec<Vec<String>>,
|
||||
invocation: Invocation,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<Vec<String>>, CallError> {
|
||||
if self.exports.test_results.is_none() {
|
||||
return Err(CallError::missing_export("test-results"));
|
||||
}
|
||||
self.call(invocation, timeout, |e, store| {
|
||||
e.test_results.as_ref().unwrap().call_select_tests(store, &config, &tests)
|
||||
})
|
||||
}
|
||||
|
||||
/// One editor-budget batch of a plugin test parser.
|
||||
pub fn parse_test_results(
|
||||
&mut self,
|
||||
config: String,
|
||||
chunk: String,
|
||||
invocation: Invocation,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::PluginTestEvent>, CallError> {
|
||||
if self.exports.test_results.is_none() {
|
||||
return Err(CallError::missing_export("test-results"));
|
||||
}
|
||||
let items = self.call(invocation, timeout, |e, store| {
|
||||
e.test_results.as_ref().unwrap().call_parse(store, &config, &chunk)
|
||||
})?;
|
||||
if items.len() > 10000 {
|
||||
return Err(CallError::Guest(GuestError::Invalid("too many test events".into())));
|
||||
}
|
||||
Ok(items.into_iter().map(test_event_from_wit).collect())
|
||||
}
|
||||
|
||||
/// The component exports `syntax-provider`.
|
||||
pub fn has_syntax(&self) -> bool {
|
||||
self.exports.syntax.is_some()
|
||||
@@ -1809,6 +2201,392 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_event_from_wit(event: wit_tests::TestEvent) -> crate::PluginTestEvent {
|
||||
use crate::{PluginTestEvent as E, TestFailed, TestOutput, TestPassed, TestSkipped, TestStart};
|
||||
let location = |l: wit_types::Location| crate::Location {
|
||||
path: l.path.into(),
|
||||
range: ide_core::Range::new(
|
||||
ide_core::Pos::new(l.range.start.line, l.range.start.character),
|
||||
ide_core::Pos::new(l.range.end.line, l.range.end.character),
|
||||
),
|
||||
};
|
||||
let start = |v: wit_tests::TestStart| TestStart {
|
||||
id: v.id,
|
||||
parent: v.parent,
|
||||
name: v.name,
|
||||
location: v.location.map(location),
|
||||
};
|
||||
match event {
|
||||
wit_tests::TestEvent::SuiteStarted(v) => E::SuiteStarted(start(v)),
|
||||
wit_tests::TestEvent::TestStarted(v) => E::TestStarted(start(v)),
|
||||
wit_tests::TestEvent::Passed(v) => E::Passed(TestPassed { id: v.id, duration_ms: v.duration_ms }),
|
||||
wit_tests::TestEvent::Failed(v) => E::Failed(TestFailed {
|
||||
id: v.id,
|
||||
duration_ms: v.duration_ms,
|
||||
message: v.message,
|
||||
expected: v.expected,
|
||||
actual: v.actual,
|
||||
location: v.location.map(location),
|
||||
}),
|
||||
wit_tests::TestEvent::Skipped(v) => E::Skipped(TestSkipped { id: v.id, reason: v.reason }),
|
||||
wit_tests::TestEvent::Output(v) => E::Output(TestOutput { id: v.id, text: v.text }),
|
||||
wit_tests::TestEvent::Other(v) => E::Other { kind: v.kind, data: v.data },
|
||||
}
|
||||
}
|
||||
|
||||
fn parameter_from(v: wit_lf::Parameter) -> crate::language_features::Parameter {
|
||||
crate::language_features::Parameter {
|
||||
label: parameter_label_from(v.label),
|
||||
documentation: v.documentation.map(|v| crate::runtime::clip(v, 65536)),
|
||||
}
|
||||
}
|
||||
fn signature_from(v: wit_lf::Signature) -> crate::language_features::Signature {
|
||||
crate::language_features::Signature {
|
||||
label: crate::runtime::clip(v.label, 65536),
|
||||
documentation: v.documentation.map(|v| crate::runtime::clip(v, 65536)),
|
||||
parameters: v.parameters.into_iter().take(10000).map(parameter_from).collect(),
|
||||
}
|
||||
}
|
||||
fn signature_help_from(v: wit_lf::SignatureHelpResult) -> crate::language_features::SignatureHelp {
|
||||
crate::language_features::SignatureHelp {
|
||||
signatures: v.signatures.into_iter().take(10000).map(signature_from).collect(),
|
||||
active_signature: v.active_signature,
|
||||
active_parameter: v.active_parameter,
|
||||
}
|
||||
}
|
||||
fn highlight_from(v: wit_lf::Highlight) -> crate::language_features::Highlight {
|
||||
crate::language_features::Highlight { range: range_from_wit(v.range), kind: crate::runtime::clip(v.kind, 65536) }
|
||||
}
|
||||
fn document_link_from(v: wit_lf::DocumentLink) -> crate::language_features::DocumentLink {
|
||||
crate::language_features::DocumentLink {
|
||||
range: range_from_wit(v.range),
|
||||
target: crate::runtime::clip(v.target, 65536),
|
||||
}
|
||||
}
|
||||
fn code_lens_from(v: wit_lf::CodeLens) -> crate::language_features::CodeLens {
|
||||
crate::language_features::CodeLens {
|
||||
range: range_from_wit(v.range),
|
||||
title: crate::runtime::clip(v.title, 65536),
|
||||
command: crate::runtime::clip(v.command, 65536),
|
||||
argument: v.argument.map(|v| crate::runtime::clip(v, 65536)),
|
||||
}
|
||||
}
|
||||
fn fold_range_from(v: wit_lf::FoldRange) -> crate::language_features::FoldRange {
|
||||
crate::language_features::FoldRange {
|
||||
start_line: v.start_line,
|
||||
end_line: v.end_line,
|
||||
kind: crate::runtime::clip(v.kind, 65536),
|
||||
}
|
||||
}
|
||||
fn color_info_from(v: wit_lf::ColorInfo) -> crate::language_features::ColorInfo {
|
||||
crate::language_features::ColorInfo {
|
||||
range: range_from_wit(v.range),
|
||||
red: v.red,
|
||||
green: v.green,
|
||||
blue: v.blue,
|
||||
alpha: v.alpha,
|
||||
}
|
||||
}
|
||||
fn document_symbol_from(v: wit_lf::DocumentSymbol) -> crate::language_features::DocumentSymbol {
|
||||
crate::language_features::DocumentSymbol {
|
||||
name: crate::runtime::clip(v.name, 65536),
|
||||
detail: crate::runtime::clip(v.detail, 65536),
|
||||
kind: crate::runtime::clip(v.kind, 65536),
|
||||
range: range_from_wit(v.range),
|
||||
selection_range: range_from_wit(v.selection_range),
|
||||
parent: v.parent,
|
||||
}
|
||||
}
|
||||
fn gutter_action_from(v: wit_lf::GutterAction) -> crate::language_features::GutterAction {
|
||||
crate::language_features::GutterAction {
|
||||
line: v.line,
|
||||
icon: v.icon.map(|v| crate::runtime::clip(v, 65536)),
|
||||
tooltip: crate::runtime::clip(v.tooltip, 65536),
|
||||
command: crate::runtime::clip(v.command, 65536),
|
||||
argument: v.argument.map(|v| crate::runtime::clip(v, 65536)),
|
||||
}
|
||||
}
|
||||
fn parameter_label_from(v: wit_lf::ParameterLabel) -> crate::language_features::ParameterLabel {
|
||||
use crate::language_features::ParameterLabel as P;
|
||||
match v {
|
||||
wit_lf::ParameterLabel::Text(t) => P::Text(clip(t, 4096)),
|
||||
wit_lf::ParameterLabel::Offsets(t) => P::Offsets(t),
|
||||
}
|
||||
}
|
||||
impl Runtime {
|
||||
fn language_feature_call<R>(
|
||||
&mut self,
|
||||
timeout: Duration,
|
||||
f: impl FnOnce(&wit_lf::Guest, &mut Store<StoreData>) -> wasmtime::Result<R>,
|
||||
) -> Result<R, CallError> {
|
||||
if self.exports.language_features.is_none() {
|
||||
return Err(CallError::missing_export("language-features"));
|
||||
}
|
||||
self.call(Invocation::default(), timeout, |e, s| f(e.language_features.as_ref().expect("checked"), s))
|
||||
}
|
||||
pub fn language_format(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
range: Option<ide_core::Range>,
|
||||
options: crate::language_features::FormatOptions,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<ide_core::TextEdit>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| {
|
||||
p.call_format(
|
||||
s,
|
||||
&d,
|
||||
range.map(range_to_wit),
|
||||
wit_lf::FormatOptions { tab_size: options.tab_size, insert_spaces: options.insert_spaces },
|
||||
)
|
||||
})?;
|
||||
let result = result.map_err(|e| CallError::Guest(e.into()))?;
|
||||
Ok(result.into_iter().take(10000).map(edit_from_wit).collect())
|
||||
}
|
||||
pub fn language_signature_help(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<crate::language_features::SignatureHelp>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_signature_help(s, &d, pos_to_wit(at)))?;
|
||||
Ok(result.map(signature_help_from))
|
||||
}
|
||||
pub fn language_document_highlights(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::language_features::Highlight>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_document_highlights(s, &d, pos_to_wit(at)))?;
|
||||
Ok(result.into_iter().take(10000).map(highlight_from).collect())
|
||||
}
|
||||
pub fn language_document_links(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::language_features::DocumentLink>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_document_links(s, &d))?;
|
||||
Ok(result.into_iter().take(10000).map(document_link_from).collect())
|
||||
}
|
||||
pub fn language_code_lenses(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::language_features::CodeLens>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_code_lenses(s, &d))?;
|
||||
Ok(result.into_iter().take(10000).map(code_lens_from).collect())
|
||||
}
|
||||
pub fn language_selection_ranges(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<ide_core::Range>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_selection_ranges(s, &d, pos_to_wit(at)))?;
|
||||
Ok(result.into_iter().take(10000).map(range_from_wit).collect())
|
||||
}
|
||||
pub fn language_folding(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::language_features::FoldRange>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_folding(s, &d))?;
|
||||
Ok(result.into_iter().take(10000).map(fold_range_from).collect())
|
||||
}
|
||||
pub fn language_colors(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::language_features::ColorInfo>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_colors(s, &d))?;
|
||||
Ok(result.into_iter().take(10000).map(color_info_from).collect())
|
||||
}
|
||||
pub fn language_color_presentations(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
color: crate::language_features::ColorInfo,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<String>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| {
|
||||
p.call_color_presentations(
|
||||
s,
|
||||
&d,
|
||||
wit_lf::ColorInfo {
|
||||
range: range_to_wit(color.range),
|
||||
red: color.red,
|
||||
green: color.green,
|
||||
blue: color.blue,
|
||||
alpha: color.alpha,
|
||||
},
|
||||
)
|
||||
})?;
|
||||
Ok(result.into_iter().take(10000).map(|v| crate::runtime::clip(v, 65536)).collect())
|
||||
}
|
||||
pub fn language_document_symbols(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::language_features::DocumentSymbol>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_document_symbols(s, &d))?;
|
||||
Ok(result.into_iter().take(10000).map(document_symbol_from).collect())
|
||||
}
|
||||
pub fn language_implementation(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::Location>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_implementation(s, &d, pos_to_wit(at)))?;
|
||||
Ok(result.into_iter().take(10000).map(location_from_wit).collect())
|
||||
}
|
||||
pub fn language_type_definition(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::Location>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_type_definition(s, &d, pos_to_wit(at)))?;
|
||||
Ok(result.into_iter().take(10000).map(location_from_wit).collect())
|
||||
}
|
||||
pub fn language_gutter_actions(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::language_features::GutterAction>, CallError> {
|
||||
let d = wit_lf::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.language_feature_call(timeout, |p, s| p.call_gutter_actions(s, &d))?;
|
||||
Ok(result.into_iter().take(10000).map(gutter_action_from).collect())
|
||||
}
|
||||
pub fn provide_injections(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<crate::language_features::Injection>, CallError> {
|
||||
if self.exports.injections.is_none() {
|
||||
return Err(CallError::missing_export("injections"));
|
||||
}
|
||||
let d = wit_inj::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let result = self.call(Invocation::default(), timeout, |e, s| {
|
||||
e.injections.as_ref().expect("checked").call_injections(s, &d)
|
||||
})?;
|
||||
Ok(result
|
||||
.into_iter()
|
||||
.take(10000)
|
||||
.map(|i| crate::language_features::Injection {
|
||||
range: range_from_wit(i.range),
|
||||
language: clip(i.language, 64),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// Optional MCP export, with the same wall-clock and fuel budgets as commands.
|
||||
pub fn mcp_call(&mut self, tool: &str, arguments: &str, invocation: Invocation) -> Result<String, CallError> {
|
||||
if self.exports.mcp.is_none() {
|
||||
return Err(CallError::missing_export("mcp-provider"));
|
||||
}
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let result = e.mcp.as_ref().unwrap().call_call(
|
||||
&mut *store,
|
||||
tool,
|
||||
arguments,
|
||||
wasmtime::component::Resource::new_borrow(ctx.rep()),
|
||||
);
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
result
|
||||
})?;
|
||||
let result = result.map_err(|e| CallError::Guest(e.into()))?;
|
||||
if result.len() > 4 << 20 {
|
||||
return Err(CallError::Guest(GuestError::Invalid("MCP result exceeds 4 MiB".into())));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 root = dunce::canonicalize(root).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))
|
||||
@@ -31,7 +31,7 @@ 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() {
|
||||
match dunce::canonicalize(&candidate) {
|
||||
Ok(real) => break real,
|
||||
Err(_) => {
|
||||
let Some(last) = candidate.components().next_back() else {
|
||||
@@ -58,7 +58,7 @@ pub fn resolve_real(joined: &Path) -> PathBuf {
|
||||
/// 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"))?;
|
||||
let real = dunce::canonicalize(&joined).map_err(|_| format!("{path}: no such file"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{path}: access outside the project root is not allowed"));
|
||||
}
|
||||
@@ -83,7 +83,7 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() {
|
||||
return Err("empty path".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let root = dunce::canonicalize(root).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();
|
||||
@@ -108,7 +108,7 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
missing.push(dir);
|
||||
existing = above;
|
||||
}
|
||||
let mut real = existing.canonicalize().map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut real = dunce::canonicalize(existing).map_err(|e| format!("{path}: {e}"))?;
|
||||
real.extend(missing.into_iter().rev());
|
||||
real.push(name);
|
||||
if !real.starts_with(&root) {
|
||||
@@ -207,9 +207,8 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
|
||||
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()
|
||||
Wanted::Read => dunce::canonicalize(&asked).map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?,
|
||||
Wanted::Write => dunce::canonicalize(&asked)
|
||||
.or_else(|_| resolve_unsaved(&asked))
|
||||
.map_err(|e| Refusal::Invalid(format!("{path}: {e}")))?,
|
||||
};
|
||||
@@ -223,7 +222,7 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
|
||||
};
|
||||
// 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() {
|
||||
let base = match dunce::canonicalize(expand_tilde(base)) {
|
||||
Ok(real) => strip_verbatim(&real),
|
||||
Err(_) => strip_verbatim(&expand_tilde(base)),
|
||||
};
|
||||
@@ -246,20 +245,73 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
|
||||
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())?;
|
||||
let dir = dunce::canonicalize(parent).map_err(|_| "its directory does not exist".to_string())?;
|
||||
Ok(dir.join(name))
|
||||
}
|
||||
|
||||
/// `\\?\C:\…` (what `canonicalize` returns on Windows) as `C:\…`.
|
||||
/// Simplifies Windows drive paths only when the plain form preserves their meaning.
|
||||
fn strip_verbatim(path: &Path) -> String {
|
||||
let text = path.to_string_lossy();
|
||||
text.strip_prefix(r"\\?\").unwrap_or(&text).to_owned()
|
||||
dunce::simplified(path).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn grant_paths_simplify_drive_paths_and_preserve_unc_prefixes() {
|
||||
assert_eq!(strip_verbatim(Path::new(r"\\?\C:\project\a.txt")), r"C:\project\a.txt");
|
||||
for path in [r"C:\project\a.txt", r"\\?\UNC\server\share\a.txt", r"\\server\share\a.txt"] {
|
||||
assert_eq!(strip_verbatim(Path::new(path)), path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn resolvers_return_plain_paths_for_plain_and_verbatim_inputs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::write(root.join("a.txt"), "text").unwrap();
|
||||
let plain = dunce::canonicalize(&root).unwrap();
|
||||
let verbatim = std::fs::canonicalize(&root).unwrap();
|
||||
assert_ne!(plain, verbatim);
|
||||
let file = plain.join("a.txt");
|
||||
let missing = plain.join("new/deep/a.txt");
|
||||
for root in [&plain, &verbatim] {
|
||||
for path in ["a.txt", file.to_str().unwrap(), verbatim.join("a.txt").to_str().unwrap()] {
|
||||
assert_eq!(resolve_in_root(root, path).unwrap(), file);
|
||||
assert_eq!(resolve_new_in_root(root, path).unwrap(), file);
|
||||
assert!(inside_root(root, path).unwrap());
|
||||
}
|
||||
for path in ["new/deep/a.txt", missing.to_str().unwrap(), verbatim.join("new/deep/a.txt").to_str().unwrap()]
|
||||
{
|
||||
assert_eq!(resolve_new_in_root(root, path).unwrap(), missing);
|
||||
assert_eq!(resolve_real(&root.join(path)), missing);
|
||||
assert!(inside_root(root, path).unwrap());
|
||||
}
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "secret").unwrap();
|
||||
assert!(resolve_in_root(root, outside.to_str().unwrap()).is_err());
|
||||
assert!(resolve_new_in_root(root, outside.to_str().unwrap()).is_err());
|
||||
}
|
||||
for root in [&plain, &verbatim] {
|
||||
let read = Capability::FsRead(root.to_string_lossy().into_owned());
|
||||
let write = Capability::FsWrite(root.to_string_lossy().into_owned());
|
||||
for path in [file.clone(), verbatim.join("a.txt")] {
|
||||
assert_eq!(resolve_granted(std::slice::from_ref(&read), path.to_str().unwrap()).unwrap(), file);
|
||||
assert_eq!(resolve_writable(std::slice::from_ref(&write), path.to_str().unwrap()).unwrap(), file);
|
||||
}
|
||||
for path in [plain.join("new.txt"), verbatim.join("new.txt")] {
|
||||
assert_eq!(
|
||||
resolve_writable(std::slice::from_ref(&write), path.to_str().unwrap()).unwrap(),
|
||||
plain.join("new.txt")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confines_reads_to_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -310,7 +362,7 @@ mod tests {
|
||||
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();
|
||||
let root_real = dunce::canonicalize(&root).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"));
|
||||
@@ -329,7 +381,7 @@ mod tests {
|
||||
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();
|
||||
let root_real = dunce::canonicalize(&root).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"));
|
||||
@@ -362,13 +414,13 @@ mod tests {
|
||||
/// 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()))
|
||||
Capability::FsRead(strip_verbatim(&dunce::canonicalize(dir).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()
|
||||
dunce::canonicalize(file).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+167
-22
@@ -25,6 +25,8 @@ pub struct EditorContext {
|
||||
/// Where a command or event batch comes from: the window and its project.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Invocation {
|
||||
/// Plugin instances waiting on this command chain; never re-enter one.
|
||||
pub command_chain: Vec<String>,
|
||||
pub client: Option<String>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
/// Settings actions can read the global layer while retaining their project context.
|
||||
@@ -35,7 +37,7 @@ pub struct Invocation {
|
||||
}
|
||||
|
||||
/// A failed host call, as the plugin sees it (WIT `types.error`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HostError {
|
||||
/// Not granted; names the capability.
|
||||
Denied(String),
|
||||
@@ -98,6 +100,12 @@ pub enum Prompt {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum EventKind {
|
||||
FileClosed,
|
||||
SettingsChanged,
|
||||
InputModeChanged,
|
||||
RunExited,
|
||||
TerminalExited,
|
||||
ContentTabAction,
|
||||
FileOpened,
|
||||
FileSaved,
|
||||
FilesChanged,
|
||||
@@ -108,7 +116,13 @@ pub enum EventKind {
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 7] = [
|
||||
pub const ALL: [Self; 13] = [
|
||||
Self::FileClosed,
|
||||
Self::SettingsChanged,
|
||||
Self::InputModeChanged,
|
||||
Self::RunExited,
|
||||
Self::TerminalExited,
|
||||
Self::ContentTabAction,
|
||||
Self::FileOpened,
|
||||
Self::FileSaved,
|
||||
Self::FilesChanged,
|
||||
@@ -120,6 +134,12 @@ impl EventKind {
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::FileClosed => "file-closed",
|
||||
Self::SettingsChanged => "settings-changed",
|
||||
Self::InputModeChanged => "input-mode-changed",
|
||||
Self::RunExited => "run-exited",
|
||||
Self::TerminalExited => "terminal-exited",
|
||||
Self::ContentTabAction => "content-tab-action",
|
||||
Self::FileOpened => "file-opened",
|
||||
Self::FileSaved => "file-saved",
|
||||
Self::FilesChanged => "files-changed",
|
||||
@@ -138,8 +158,8 @@ impl EventKind {
|
||||
/// manifest declares).
|
||||
pub fn capability(self) -> Option<ide_extension::Capability> {
|
||||
match self {
|
||||
Self::ActionPerformed => Some(ide_extension::Capability::InputEvents),
|
||||
Self::Scheduled => None,
|
||||
Self::ActionPerformed | Self::InputModeChanged => Some(ide_extension::Capability::InputEvents),
|
||||
Self::Scheduled | Self::RunExited | Self::TerminalExited | Self::ContentTabAction => None,
|
||||
_ => Some(ide_extension::Capability::Events),
|
||||
}
|
||||
}
|
||||
@@ -188,10 +208,24 @@ pub struct FilesChanged {
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
/// The focused editor's input mode and optional visual sub-mode.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct InputMode {
|
||||
pub path: String,
|
||||
pub mode: String,
|
||||
pub sub: Option<String>,
|
||||
}
|
||||
|
||||
/// A workbench event delivered to plugins.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum WorkbenchEvent {
|
||||
RunExited(crate::RunExited),
|
||||
TerminalExited(crate::TerminalExited),
|
||||
ContentTabAction(crate::ContentTabAction),
|
||||
FileClosed(String),
|
||||
SettingsChanged(Vec<String>),
|
||||
InputModeChanged(InputMode),
|
||||
FileOpened(String),
|
||||
FileSaved(String),
|
||||
FilesChanged(FilesChanged),
|
||||
@@ -204,6 +238,12 @@ pub enum WorkbenchEvent {
|
||||
impl WorkbenchEvent {
|
||||
pub fn kind(&self) -> EventKind {
|
||||
match self {
|
||||
Self::RunExited(_) => EventKind::RunExited,
|
||||
Self::TerminalExited(_) => EventKind::TerminalExited,
|
||||
Self::ContentTabAction(_) => EventKind::ContentTabAction,
|
||||
Self::FileClosed(_) => EventKind::FileClosed,
|
||||
Self::SettingsChanged(_) => EventKind::SettingsChanged,
|
||||
Self::InputModeChanged(_) => EventKind::InputModeChanged,
|
||||
Self::FileOpened(_) => EventKind::FileOpened,
|
||||
Self::FileSaved(_) => EventKind::FileSaved,
|
||||
Self::FilesChanged(_) => EventKind::FilesChanged,
|
||||
@@ -350,6 +390,8 @@ pub struct HttpRequest {
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<Vec<u8>>,
|
||||
pub timeout: Duration,
|
||||
/// Grants checked again before following a redirect.
|
||||
pub grants: Vec<ide_extension::Capability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -371,6 +413,8 @@ pub struct ProcessOutput {
|
||||
/// the environment to add to the inherited one (WIT `process.command`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProcessCommand {
|
||||
/// The invoking project whose executable override applies, independent of cwd.
|
||||
pub approval_root: Option<PathBuf>,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
@@ -388,23 +432,27 @@ pub struct ProcessChunk {
|
||||
/// Where `net:` requests may go: the URL's host when it is `https`, or
|
||||
/// `http` to the loopback interface.
|
||||
pub fn net_host(url: &str) -> Result<String, String> {
|
||||
let (scheme, rest) = url.split_once("://").ok_or_else(|| format!("{url:?} is not an absolute URL"))?;
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||
let host = if let Some(v6) = authority.strip_prefix('[') {
|
||||
v6.split(']').next().unwrap_or_default().to_owned()
|
||||
} else {
|
||||
authority.split(':').next().unwrap_or_default().to_ascii_lowercase()
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err(format!("{url:?} has no host"));
|
||||
let parsed = url::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(format!("unsupported scheme {:?}", parsed.scheme()));
|
||||
}
|
||||
let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"https" => Ok(host),
|
||||
"http" if loopback => Ok(host),
|
||||
"http" => Err(format!("{url:?}: plain http is only allowed to localhost")),
|
||||
other => Err(format!("{url:?}: unsupported scheme {other:?}")),
|
||||
match parsed.host() {
|
||||
Some(url::Host::Domain(host)) => Ok(host.trim_end_matches('.').to_ascii_lowercase()),
|
||||
Some(url::Host::Ipv4(host)) => Ok(host.to_string()),
|
||||
Some(url::Host::Ipv6(host)) => Ok(host.to_string()),
|
||||
None => Err("URL has no host".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The grant required by a URL; loopback HTTP retains the original `net:` rule.
|
||||
pub fn net_capability(url: &str) -> Result<ide_extension::Capability, String> {
|
||||
let host = net_host(url)?;
|
||||
if url::Url::parse(url).is_ok_and(|u| u.scheme() == "http")
|
||||
&& !matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1")
|
||||
{
|
||||
Ok(ide_extension::Capability::NetHttp(host))
|
||||
} else {
|
||||
Ok(ide_extension::Capability::Net(host))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +475,10 @@ mod tests {
|
||||
assert_eq!(net_host("https://API.example.com:8443/v1?x").unwrap(), "api.example.com");
|
||||
assert_eq!(net_host("http://localhost:11434/api").unwrap(), "localhost");
|
||||
assert_eq!(net_host("https://user:[email protected]/").unwrap(), "h.io");
|
||||
assert!(net_host("http://example.com/").is_err());
|
||||
assert_eq!(
|
||||
net_capability("http://example.com/").unwrap(),
|
||||
ide_extension::Capability::NetHttp("example.com".into())
|
||||
);
|
||||
assert!(net_host("file:///etc/passwd").is_err());
|
||||
assert!(net_host("example.com").is_err());
|
||||
}
|
||||
@@ -442,7 +493,8 @@ pub struct ProviderDocument {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename = "PluginLocation")]
|
||||
pub struct Location {
|
||||
pub path: PathBuf,
|
||||
pub range: ide_core::Range,
|
||||
@@ -710,3 +762,96 @@ pub struct NotificationAction {
|
||||
pub command: String,
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
|
||||
/// An open editor reported by its window.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenEditor {
|
||||
pub path: String,
|
||||
pub language: String,
|
||||
pub dirty: bool,
|
||||
pub active: bool,
|
||||
}
|
||||
/// Find in Files options.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextQuery {
|
||||
pub pattern: String,
|
||||
pub regex: bool,
|
||||
pub case_sensitive: bool,
|
||||
pub whole_word: bool,
|
||||
pub include: Vec<String>,
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
/// A search hit, using UTF-16 coordinates.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
pub struct TextMatch {
|
||||
pub location: Location,
|
||||
pub line: String,
|
||||
}
|
||||
/// Bounded search results.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextSearch {
|
||||
pub matches: Vec<TextMatch>,
|
||||
pub limit_hit: bool,
|
||||
}
|
||||
/// A file's problems from one producer.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
pub struct FileProblems {
|
||||
pub path: String,
|
||||
pub source: String,
|
||||
pub diagnostics: Vec<ide_core::Diagnostic>,
|
||||
}
|
||||
/// An editor decoration.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct Decoration {
|
||||
pub range: ide_core::Range,
|
||||
pub style: String,
|
||||
pub hover: Option<String>,
|
||||
pub after: Option<String>,
|
||||
}
|
||||
/// A tree decoration.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct FileDecoration {
|
||||
pub badge: Option<String>,
|
||||
pub style: String,
|
||||
pub tooltip: Option<String>,
|
||||
}
|
||||
/// Commit metadata, date in Unix seconds.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitInfo {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub author: String,
|
||||
pub author_email: String,
|
||||
pub date: i64,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
pub parents: Vec<String>,
|
||||
}
|
||||
/// One zero-based line's attribution, date in Unix seconds.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
pub struct BlameLine {
|
||||
pub line: u32,
|
||||
pub commit: String,
|
||||
pub author: String,
|
||||
pub date: i64,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
/// Non-secret settings of a plugin-owned project database source (API 1.4).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginDataSource {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub driver: String,
|
||||
pub host: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub database: Option<String>,
|
||||
pub user: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub options: Vec<(String, String)>,
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use crate::runtime::{MAX_TITLE, StoreData, clip};
|
||||
use crate::sandbox;
|
||||
use crate::types::{
|
||||
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, ProcessChunk, ProcessCommand,
|
||||
Prompt, StatusItem, net_host,
|
||||
Prompt, StatusItem, net_capability,
|
||||
};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_extension::Capability;
|
||||
@@ -25,6 +25,8 @@ pub(crate) mod bindings {
|
||||
"openidea:plugin/invocation.invocation": super::InvocationRes,
|
||||
"openidea:plugin/tasks.task": super::TaskRes,
|
||||
"openidea:plugin/process.child": super::ChildRes,
|
||||
"openidea:plugin/ipc.connection": super::ConnectionRes,
|
||||
"openidea:plugin/auth.loopback": super::LoopbackRes,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,6 +44,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/decorations": super::bindings::openidea::plugin::decorations,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
@@ -50,10 +53,17 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/run": super::bindings::openidea::plugin::run,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/output": super::bindings::openidea::plugin::output,
|
||||
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
|
||||
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
|
||||
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -71,6 +81,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/decorations": super::bindings::openidea::plugin::decorations,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
@@ -79,10 +90,17 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/run": super::bindings::openidea::plugin::run,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/output": super::bindings::openidea::plugin::output,
|
||||
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
|
||||
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
|
||||
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -100,6 +118,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/decorations": super::bindings::openidea::plugin::decorations,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
@@ -108,17 +127,24 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
||||
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
||||
"openidea:plugin/run": super::bindings::openidea::plugin::run,
|
||||
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
||||
"openidea:plugin/output": super::bindings::openidea::plugin::output,
|
||||
"openidea:plugin/auth": super::bindings::openidea::plugin::auth,
|
||||
"openidea:plugin/command-runner": super::bindings::openidea::plugin::command_runner,
|
||||
"openidea:plugin/ipc": super::bindings::openidea::plugin::ipc,
|
||||
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
||||
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
use bindings::openidea::plugin::{
|
||||
commit, detail_types, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage,
|
||||
tasks, types as wit, ui, vcs, views, workspace,
|
||||
commit, detail_types, diagnostics, editor, events, fs, invocation, ipc, log, net, process, secrets, settings,
|
||||
storage, tasks, types as wit, ui, vcs, views, workspace,
|
||||
};
|
||||
|
||||
/// A project handle: its root.
|
||||
@@ -319,6 +345,14 @@ impl StoreData {
|
||||
self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple })
|
||||
}
|
||||
|
||||
fn require_window(&self) -> Result<(), wit::Error> {
|
||||
self.require_interactive()?;
|
||||
if self.invocation.client.is_none() {
|
||||
return Err(wit::Error::Invalid("requires an invoking window".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// APIs reserved for commands and events exclude code-action resolution.
|
||||
fn require_interactive(&self) -> Result<(), wit::Error> {
|
||||
if self.interactive {
|
||||
@@ -381,6 +415,29 @@ impl ui::Host for StoreData {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_window_status_item(&mut self, id: String, item: ui::StatusItem) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let item = StatusItem {
|
||||
text: clip(item.text, MAX_STATUS_TEXT),
|
||||
tooltip: item.tooltip.map(|t| clip(t, 1024)),
|
||||
command: item.command.map(|c| clip(c, 128)),
|
||||
};
|
||||
Ok(self
|
||||
.host
|
||||
.window_status_item(self.invocation.client.as_deref().expect("checked"), &clip(id, 64), Some(item))
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn remove_window_status_item(&mut self, id: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self
|
||||
.host
|
||||
.window_status_item(self.invocation.client.as_deref().expect("checked"), &clip(id, 64), None)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn set_context(&mut self, key: String, value: ui::ContextValue) -> Fallible<()> {
|
||||
let valid_key =
|
||||
(1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'));
|
||||
@@ -532,6 +589,44 @@ impl workspace::Host for StoreData {
|
||||
}
|
||||
|
||||
impl workspace::HostProject for StoreData {
|
||||
fn search_text(
|
||||
&mut self,
|
||||
this: Resource<ProjectRes>,
|
||||
query: workspace::TextQuery,
|
||||
limit: u32,
|
||||
) -> Fallible<workspace::TextSearch> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = &self.table.get(&this)?.root;
|
||||
let query = crate::TextQuery {
|
||||
pattern: query.pattern,
|
||||
regex: query.regex,
|
||||
case_sensitive: query.case_sensitive,
|
||||
whole_word: query.whole_word,
|
||||
include: query.include,
|
||||
exclude: query.exclude,
|
||||
};
|
||||
Ok(self
|
||||
.host
|
||||
.search_text(self.client(), root, query, limit.min(10_000), &self.cancel.token(), self.deadline)
|
||||
.map(|s| workspace::TextSearch {
|
||||
limit_hit: s.limit_hit,
|
||||
matches: s
|
||||
.matches
|
||||
.into_iter()
|
||||
.map(|m| workspace::TextMatch {
|
||||
location: wit::Location {
|
||||
path: to_ipc(&m.location.path),
|
||||
range: range_to_wit(&m.location.range),
|
||||
},
|
||||
line: m.line,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
|
||||
fn root(&mut self, this: Resource<ProjectRes>) -> Fallible<String> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -659,11 +754,94 @@ impl workspace::HostProject for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
impl editor::Host for StoreData {}
|
||||
impl editor::Host for StoreData {
|
||||
fn open_editors(&mut self) -> Fallible<Vec<editor::OpenEditor>> {
|
||||
if let Err(e) = self.require(Capability::EditorRead).and_then(|_| self.require_window()) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self
|
||||
.host
|
||||
.open_editors(self.client().unwrap())
|
||||
.map(|items| {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|e| editor::OpenEditor {
|
||||
path: e.path,
|
||||
language: e.language,
|
||||
dirty: e.dirty,
|
||||
active: e.active,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn open(&mut self, path: String, selection: Option<wit::Range>) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let real = match self.openable_path(&path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
Ok(self.host.editor_open(self.client().unwrap(), &real, selection.map(core_range)).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
fn core_range(r: wit::Range) -> ide_core::Range {
|
||||
ide_core::Range::new(pos(r.start), pos(r.end))
|
||||
}
|
||||
impl StoreData {
|
||||
fn decoration_path(&self, path: &str) -> Result<PathBuf, wit::Error> {
|
||||
if Path::new(path).is_absolute() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
let root = self
|
||||
.invocation
|
||||
.workspace_root
|
||||
.as_ref()
|
||||
.ok_or_else(|| wit::Error::Invalid("relative decorations need an invoking project".into()))?;
|
||||
sandbox::resolve_in_root(root, path).map_err(wit::Error::Invalid)
|
||||
}
|
||||
fn openable_path(&self, path: &str) -> Result<PathBuf, wit::Error> {
|
||||
if let Ok(dir) = self.host.plugin_dir()
|
||||
&& let Ok(real) = sandbox::resolve_granted(&[Capability::FsRead(to_ipc(&dir))], path)
|
||||
{
|
||||
return Ok(real);
|
||||
}
|
||||
self.require(Capability::ProjectRead)?;
|
||||
let root = self
|
||||
.invocation
|
||||
.workspace_root
|
||||
.as_ref()
|
||||
.ok_or_else(|| wit::Error::Invalid("no project is open in this window".into()))?;
|
||||
sandbox::resolve_in_root(root, path).map_err(wit::Error::Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
impl detail_types::Host for StoreData {}
|
||||
|
||||
impl editor::HostTextEditor for StoreData {
|
||||
fn set_selections(&mut self, this: Resource<EditorRes>, selections: Vec<wit::Range>) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if selections.is_empty() || selections.len() > 1000 {
|
||||
return Ok(Err(wit::Error::Invalid("requires 1-1000 selections".into())));
|
||||
}
|
||||
let path = &self.table.get(&this)?.ctx.path;
|
||||
Ok(self
|
||||
.host
|
||||
.editor_ranges(self.client().unwrap(), path, selections.into_iter().map(core_range).collect(), false)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn reveal(&mut self, this: Resource<EditorRes>, range: wit::Range) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let path = &self.table.get(&this)?.ctx.path;
|
||||
Ok(self.host.editor_ranges(self.client().unwrap(), path, vec![core_range(range)], true).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
|
||||
}
|
||||
@@ -748,6 +926,69 @@ impl invocation::HostInvocation for StoreData {
|
||||
}
|
||||
|
||||
impl vcs::Host for StoreData {
|
||||
fn log(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
path: Option<String>,
|
||||
limit: u32,
|
||||
) -> Fallible<Vec<vcs::CommitInfo>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = &self.table.get(&project)?.root;
|
||||
Ok(self
|
||||
.host
|
||||
.vcs_log(root, path.as_deref(), limit.min(1000), &self.cancel.token())
|
||||
.map(|v| {
|
||||
v.into_iter()
|
||||
.map(|c| vcs::CommitInfo {
|
||||
id: c.id,
|
||||
short_id: c.short_id,
|
||||
author: c.author,
|
||||
author_email: c.author_email,
|
||||
date: c.date,
|
||||
subject: c.subject,
|
||||
body: c.body,
|
||||
parents: c.parents,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn blame(&mut self, project: Resource<ProjectRes>, path: String) -> Fallible<Vec<vcs::BlameLine>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = &self.table.get(&project)?.root;
|
||||
Ok(self
|
||||
.host
|
||||
.vcs_blame(self.client(), root, &path)
|
||||
.map(|v| {
|
||||
v.into_iter()
|
||||
.map(|c| vcs::BlameLine {
|
||||
line: c.line,
|
||||
commit: c.commit,
|
||||
author: c.author,
|
||||
date: c.date,
|
||||
summary: c.summary,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn diff(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
path: Option<String>,
|
||||
against: Option<String>,
|
||||
) -> Fallible<Option<String>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = &self.table.get(&project)?.root;
|
||||
Ok(self.host.vcs_diff(root, path.as_deref(), against.as_deref(), &self.cancel.token()).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn status(&mut self, project: Resource<ProjectRes>) -> Fallible<Option<vcs::RepositoryStatus>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -850,6 +1091,18 @@ impl events::Host for StoreData {
|
||||
let Some(kind) = EventKind::parse(&kind) else {
|
||||
return Ok(Err(wit::Error::Invalid(format!("no event kind {kind:?} in this OpenIDEA"))));
|
||||
};
|
||||
if matches!(
|
||||
kind,
|
||||
EventKind::FileClosed
|
||||
| EventKind::SettingsChanged
|
||||
| EventKind::InputModeChanged
|
||||
| EventKind::RunExited
|
||||
| EventKind::TerminalExited
|
||||
| EventKind::ContentTabAction
|
||||
) && let Err(e) = self.host.require_api(kind.name(), 4)
|
||||
{
|
||||
return Ok(Err(to_wit(e)));
|
||||
}
|
||||
if let Some(cap) = kind.capability()
|
||||
&& let Err(e) = self.require(cap)
|
||||
{
|
||||
@@ -941,6 +1194,40 @@ impl StoreData {
|
||||
}
|
||||
|
||||
impl diagnostics::Host for StoreData {
|
||||
fn problems(&mut self, path: Option<String>) -> Fallible<Vec<diagnostics::FileProblems>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if self.client().is_none() {
|
||||
return Ok(Err(wit::Error::Invalid("requires an invoking window".into())));
|
||||
}
|
||||
let real = match path {
|
||||
None => None,
|
||||
Some(path) => {
|
||||
let Some(root) = self.invocation.workspace_root.as_ref() else {
|
||||
return Ok(Err(wit::Error::Invalid("no project in this window".into())));
|
||||
};
|
||||
match sandbox::resolve_new_in_root(root, &path) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(self
|
||||
.host
|
||||
.problems(self.client().unwrap(), real.as_deref())
|
||||
.map(|v| {
|
||||
v.into_iter()
|
||||
.map(|f| diagnostics::FileProblems {
|
||||
path: f.path,
|
||||
source: f.source,
|
||||
diagnostics: f.diagnostics.into_iter().map(diagnostic_to_wit).collect(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
|
||||
fn publish(&mut self, path: String, items: Vec<diagnostics::Diagnostic>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -952,11 +1239,11 @@ impl diagnostics::Host for StoreData {
|
||||
|
||||
impl net::Host for StoreData {
|
||||
fn fetch(&mut self, req: net::Request) -> Fallible<net::Response> {
|
||||
let host = match net_host(&req.url) {
|
||||
let capability = match net_capability(&req.url) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
if let Err(e) = self.require(Capability::Net(host)) {
|
||||
if let Err(e) = self.require(capability) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
|
||||
@@ -974,6 +1261,7 @@ impl net::Host for StoreData {
|
||||
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
|
||||
body: req.body,
|
||||
timeout: wanted.min(self.remaining()),
|
||||
grants: self.granted.clone(),
|
||||
};
|
||||
let token = self.cancel.token();
|
||||
Ok(self
|
||||
@@ -989,6 +1277,21 @@ impl net::Host for StoreData {
|
||||
}
|
||||
|
||||
impl secrets::Host for StoreData {
|
||||
fn set(&mut self, name: String, value: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Secrets) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if name.len() > 128 || value.len() > 64 << 10 {
|
||||
return Ok(Err(wit::Error::Invalid("secret name or value too long".into())));
|
||||
}
|
||||
Ok(self.host.secret_set(&name, Some(&value)).map_err(to_wit))
|
||||
}
|
||||
fn delete(&mut self, name: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Secrets) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.secret_set(&name, None).map_err(to_wit))
|
||||
}
|
||||
fn is_set(&mut self, name: String) -> wasmtime::Result<bool> {
|
||||
Ok(self.granted.iter().any(|g| g.covers(&Capability::Secrets)) && self.host.secret_is_set(&name))
|
||||
}
|
||||
@@ -1013,7 +1316,12 @@ impl StoreData {
|
||||
return Err(wit::Error::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
|
||||
}
|
||||
}
|
||||
Ok(ProcessCommand { program: cmd.program, args: cmd.args, env: cmd.env })
|
||||
Ok(ProcessCommand {
|
||||
program: cmd.program,
|
||||
args: cmd.args,
|
||||
env: cmd.env,
|
||||
approval_root: self.invocation.workspace_root.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The project a call runs in, when it named one.
|
||||
@@ -1120,6 +1428,68 @@ impl process::HostChild for StoreData {
|
||||
}
|
||||
|
||||
impl views::Host for StoreData {
|
||||
fn open_terminal_session(&mut self, key: String, title: String, cmd: process::Command) -> Fallible<String> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let cmd = match self.command(cmd) {
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
Ok(self
|
||||
.host
|
||||
.open_terminal_session(
|
||||
self.invocation.client.as_deref().unwrap(),
|
||||
key,
|
||||
title,
|
||||
cmd,
|
||||
&self.cancel.token(),
|
||||
self.remaining(),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn write_terminal(&mut self, key: String, text: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if text.len() > MAX_STDIN_BYTES {
|
||||
return Ok(Err(wit::Error::Invalid("terminal write too large".into())));
|
||||
}
|
||||
Ok(self
|
||||
.host
|
||||
.write_terminal(
|
||||
self.invocation.client.as_deref().unwrap(),
|
||||
&key,
|
||||
text,
|
||||
&self.cancel.token(),
|
||||
self.remaining(),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn open_content_tab(&mut self, tab: views::ContentTab) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self
|
||||
.host
|
||||
.open_content_tab(
|
||||
self.invocation.client.as_deref().unwrap(),
|
||||
tab.into(),
|
||||
&self.cancel.token(),
|
||||
self.remaining(),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn reply_content_tab(&mut self, request_id: String, reply: views::ContentTabReply) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self
|
||||
.host
|
||||
.reply_content_tab(self.invocation.client.as_deref().unwrap(), &request_id, reply.into())
|
||||
.map_err(to_wit))
|
||||
}
|
||||
|
||||
fn refresh(&mut self, view: String) -> wasmtime::Result<()> {
|
||||
self.host.refresh_view(&clip(view, 64));
|
||||
Ok(())
|
||||
@@ -1443,6 +1813,108 @@ fn validate_ui_path(path: &str) -> Result<(), wit::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl From<bindings::openidea::plugin::views::ContentAction> for crate::ContentAction {
|
||||
fn from(v: bindings::openidea::plugin::views::ContentAction) -> Self {
|
||||
Self { id: v.id, label: v.label, icon: v.icon, primary: v.primary, save: v.save }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bindings::openidea::plugin::views::ContentTab> for crate::ContentTab {
|
||||
fn from(v: bindings::openidea::plugin::views::ContentTab) -> Self {
|
||||
Self {
|
||||
key: v.key,
|
||||
title: v.title,
|
||||
file_name: v.file_name,
|
||||
language: v.language,
|
||||
content: v.content,
|
||||
read_only: v.read_only,
|
||||
actions: v.actions.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bindings::openidea::plugin::views::ContentDiff> for crate::ContentDiff {
|
||||
fn from(v: bindings::openidea::plugin::views::ContentDiff) -> Self {
|
||||
Self { left: v.left, right: v.right, left_label: v.left_label, right_label: v.right_label, title: v.title }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bindings::openidea::plugin::views::ContentTabReply> for crate::ContentTabReply {
|
||||
fn from(v: bindings::openidea::plugin::views::ContentTabReply) -> Self {
|
||||
Self {
|
||||
content: v.content,
|
||||
saved: v.saved,
|
||||
read_only: v.read_only,
|
||||
status: v.status,
|
||||
error: v.error,
|
||||
diff: v.diff.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl bindings::openidea::plugin::run_targets::Host for StoreData {
|
||||
fn register(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
target: bindings::openidea::plugin::run_targets::RunTarget,
|
||||
) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Process(target.program.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self
|
||||
.host
|
||||
.register_run_target(
|
||||
&root,
|
||||
crate::PluginRunTarget {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
program: target.program,
|
||||
args: target.args,
|
||||
workdir_args: target.workdir_args,
|
||||
container_root: target.container_root,
|
||||
commands: target.commands,
|
||||
},
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn unregister(&mut self, project: Resource<ProjectRes>, id: String) -> wasmtime::Result<()> {
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
self.host.unregister_run_target(&root, &id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl bindings::openidea::plugin::run::Host for StoreData {
|
||||
fn start(&mut self, config: String, args: Vec<String>, debug: bool) -> Fallible<String> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if args.len() > MAX_ARGS || args.iter().any(|a| a.len() > MAX_ARG_BYTES || a.contains('\0')) {
|
||||
return Ok(Err(wit::Error::Invalid("invalid run arguments".into())));
|
||||
}
|
||||
Ok(self
|
||||
.host
|
||||
.start_run(
|
||||
self.invocation.client.as_deref().unwrap(),
|
||||
&config,
|
||||
args,
|
||||
debug,
|
||||
&self.cancel.token(),
|
||||
self.remaining(),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
pub(crate) mod run_configurations_bindings {
|
||||
wasmtime::component::bindgen!({ world: "configurations-provider", path: "../../wit" });
|
||||
}
|
||||
pub(crate) mod test_results_bindings {
|
||||
wasmtime::component::bindgen!({ world: "test-results-provider", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod project_ui_tests {
|
||||
use super::validate_ui_path;
|
||||
@@ -1489,3 +1961,310 @@ pub(crate) fn diagnostics_from_wit(items: Vec<diagnostics::Diagnostic>, source:
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An IPC connection owned by this plugin instance.
|
||||
pub struct ConnectionRes {
|
||||
id: u64,
|
||||
host: std::sync::Arc<dyn crate::HostApi>,
|
||||
}
|
||||
impl Drop for ConnectionRes {
|
||||
fn drop(&mut self) {
|
||||
self.host.ipc_close(self.id);
|
||||
}
|
||||
}
|
||||
impl ipc::Host for StoreData {
|
||||
fn connect(&mut self, name: String) -> Fallible<Resource<ConnectionRes>> {
|
||||
if let Err(e) = ide_extension::validate_ipc_name(&name, false) {
|
||||
return Ok(Err(wit::Error::Invalid(e)));
|
||||
}
|
||||
if let Err(e) = self.require(Capability::Ipc(name.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let name = match ide_extension::expand_ipc_name(&name) {
|
||||
Ok(name) => name,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
match self.host.ipc_connect(&name, &self.cancel.token(), self.remaining()) {
|
||||
Ok(id) => self.push(ConnectionRes { id, host: self.host.clone() }).map(Ok),
|
||||
Err(e) => Ok(Err(to_wit(e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ipc::HostConnection for StoreData {
|
||||
fn write(&mut self, this: Resource<ConnectionRes>, bytes: Vec<u8>) -> Fallible<()> {
|
||||
if bytes.len() > 1 << 20 {
|
||||
return Ok(Err(wit::Error::Invalid("at most 1 MiB per write".into())));
|
||||
}
|
||||
let id = self.table.get(&this)?.id;
|
||||
Ok(self.host.ipc_write(id, bytes, &self.cancel.token(), self.remaining()).map_err(to_wit))
|
||||
}
|
||||
fn read(&mut self, this: Resource<ConnectionRes>, max_bytes: u32, timeout_ms: u32) -> Fallible<Vec<u8>> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
let timeout = Duration::from_millis(u64::from(timeout_ms)).min(self.remaining());
|
||||
Ok(self.host.ipc_read(id, (max_bytes as usize).min(1 << 20), timeout, &self.cancel.token()).map_err(to_wit))
|
||||
}
|
||||
fn close(&mut self, this: Resource<ConnectionRes>) -> Fallible<()> {
|
||||
let id = self.table.get(&this)?.id;
|
||||
self.host.ipc_close(id);
|
||||
Ok(Ok(()))
|
||||
}
|
||||
fn drop(&mut self, this: Resource<ConnectionRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(this)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl bindings::openidea::plugin::command_runner::Host for StoreData {
|
||||
fn execute(&mut self, id: String, arg: Option<String>) -> Fallible<Option<String>> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if !self.host.owns_command(&id)
|
||||
&& let Err(e) = self.require(Capability::IdeCommands)
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if id.len() > 256 || arg.as_ref().is_some_and(|a| a.len() > 64 << 10) {
|
||||
return Ok(Err(wit::Error::Invalid("command id or argument too long".into())));
|
||||
}
|
||||
Ok(self
|
||||
.host
|
||||
.execute_command(&self.invocation, &id, arg, &self.cancel.token(), self.remaining())
|
||||
.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
/// A desktop OAuth listener; its URI remains available after the one-shot wait.
|
||||
pub struct LoopbackRes {
|
||||
id: u64,
|
||||
uri: String,
|
||||
host: std::sync::Arc<dyn crate::HostApi>,
|
||||
}
|
||||
impl Drop for LoopbackRes {
|
||||
fn drop(&mut self) {
|
||||
self.host.close_loopback(self.id);
|
||||
}
|
||||
}
|
||||
impl bindings::openidea::plugin::auth::Host for StoreData {
|
||||
fn begin_loopback(&mut self, path: String) -> Fallible<Resource<LoopbackRes>> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
match self.host.begin_loopback(self.invocation.client.as_deref().expect("checked"), &path) {
|
||||
Ok((id, uri)) => self.push(LoopbackRes { id, uri, host: self.host.clone() }).map(Ok),
|
||||
Err(e) => Ok(Err(to_wit(e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl bindings::openidea::plugin::auth::HostLoopback for StoreData {
|
||||
fn redirect_uri(&mut self, this: Resource<LoopbackRes>) -> wasmtime::Result<String> {
|
||||
Ok(self.table.get(&this)?.uri.clone())
|
||||
}
|
||||
fn wait(&mut self, this: Resource<LoopbackRes>, timeout_ms: u32) -> Fallible<Option<String>> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let id = self.table.get(&this)?.id;
|
||||
let started = Instant::now();
|
||||
let result = self.host.wait_loopback(id, Duration::from_millis(u64::from(timeout_ms)), &self.cancel.token());
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
Ok(result.map_err(to_wit))
|
||||
}
|
||||
fn drop(&mut self, this: Resource<LoopbackRes>) -> wasmtime::Result<()> {
|
||||
self.table.delete(this)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl bindings::openidea::plugin::output::Host for StoreData {
|
||||
fn append(&mut self, channel: String, text: String) -> Fallible<()> {
|
||||
Ok(self.host.output_write(&channel, Some(&text)).map_err(to_wit))
|
||||
}
|
||||
fn clear(&mut self, channel: String) -> Fallible<()> {
|
||||
Ok(self.host.output_write(&channel, None).map_err(to_wit))
|
||||
}
|
||||
fn reveal(&mut self, channel: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require_window() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.output_reveal(self.invocation.client.as_deref().expect("checked"), &channel).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_to_wit(d: ide_core::Diagnostic) -> diagnostics::Diagnostic {
|
||||
diagnostics::Diagnostic {
|
||||
range: range_to_wit(&d.range),
|
||||
severity: match d.severity {
|
||||
ide_core::Severity::Error => diagnostics::Severity::Error,
|
||||
ide_core::Severity::Warning => diagnostics::Severity::Warning,
|
||||
ide_core::Severity::Info => diagnostics::Severity::Info,
|
||||
ide_core::Severity::Hint => diagnostics::Severity::Hint,
|
||||
},
|
||||
message: d.message,
|
||||
code: d.code,
|
||||
source: d.source,
|
||||
tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")]
|
||||
.into_iter()
|
||||
.filter(|(yes, _)| *yes)
|
||||
.map(|(_, tag)| tag.into())
|
||||
.collect(),
|
||||
related: Vec::new(),
|
||||
}
|
||||
}
|
||||
impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
fn set(
|
||||
&mut self,
|
||||
path: String,
|
||||
set_id: String,
|
||||
items: Vec<bindings::openidea::plugin::decorations::Decoration>,
|
||||
) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if items.len() > 10_000 {
|
||||
return Ok(Err(wit::Error::Invalid("at most 10000 decorations per file".into())));
|
||||
}
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|d| crate::Decoration { range: core_range(d.range), style: d.style, hover: d.hover, after: d.after })
|
||||
.collect();
|
||||
let real = match self.decoration_path(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
Ok(self.host.decorations(Some(&real), &set_id, items).map_err(to_wit))
|
||||
}
|
||||
fn clear(&mut self, set_id: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.decorations(None, &set_id, Vec::new()).map_err(to_wit))
|
||||
}
|
||||
fn set_file(
|
||||
&mut self,
|
||||
path: String,
|
||||
set_id: String,
|
||||
item: bindings::openidea::plugin::decorations::FileDecoration,
|
||||
) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let real = match self.decoration_path(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
Ok(self
|
||||
.host
|
||||
.file_decorations(
|
||||
Some(&real),
|
||||
&set_id,
|
||||
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn clear_file(&mut self, set_id: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod language_features_bindings {
|
||||
wasmtime::component::bindgen!({ world: "editor-language-features", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
pub(crate) mod injections_bindings {
|
||||
wasmtime::component::bindgen!({ world: "language-injections", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
impl bindings::openidea::plugin::database::Host for StoreData {
|
||||
fn upsert_source(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
source: bindings::openidea::plugin::database::DataSource,
|
||||
) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::DatabaseSources) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self
|
||||
.host
|
||||
.database_upsert(
|
||||
&root,
|
||||
crate::PluginDataSource {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
driver: source.driver,
|
||||
host: source.host,
|
||||
port: source.port,
|
||||
database: source.database,
|
||||
user: source.user,
|
||||
url: source.url,
|
||||
options: source.options,
|
||||
},
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
fn remove_source(&mut self, project: Resource<ProjectRes>, id: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::DatabaseSources) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self.host.database_remove(&root, &id).map_err(to_wit))
|
||||
}
|
||||
fn sources(&mut self, project: Resource<ProjectRes>) -> Fallible<Vec<String>> {
|
||||
if let Err(e) = self.require(Capability::DatabaseSources) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self.host.database_sources(&root).map_err(to_wit))
|
||||
}
|
||||
fn set_credential(&mut self, project: Resource<ProjectRes>, id: String, password: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::DatabaseSources) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if password.len() > 64 << 10 {
|
||||
return Ok(Err(wit::Error::Invalid("credential exceeds 64 KiB".into())));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self.host.database_credential(&root, &id, password).map_err(to_wit))
|
||||
}
|
||||
fn connect(&mut self, project: Resource<ProjectRes>, id: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::DatabaseSources).and_then(|_| self.require_window()) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
let started = Instant::now();
|
||||
let result = self.host.database_connect(
|
||||
&root,
|
||||
self.client().unwrap(),
|
||||
&id,
|
||||
&self.cancel.token(),
|
||||
Duration::from_secs(180),
|
||||
);
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
Ok(result.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
impl bindings::openidea::plugin::mcp::Host for StoreData {
|
||||
fn stdio_command(&mut self) -> wasmtime::Result<Option<Vec<String>>> {
|
||||
Ok(self.client().and_then(|client| self.host.mcp_stdio_command(client)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod mcp_bindings {
|
||||
wasmtime::component::bindgen!({ world: "mcp-tools", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32,7 +32,10 @@ fn components() -> Vec<PathBuf> {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/execution/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();
|
||||
@@ -88,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.3.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.0.0-rc.1"] {
|
||||
let host = v("1.4.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.4.0", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.4.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.4") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
let newer = check_api(&v("1.5.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.5") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.3 SDK"), "{older}");
|
||||
assert!(older.contains("rebuild it with the 1.4 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# 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 = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-api14"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[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,21 @@
|
||||
[package]
|
||||
name = "openidea-plugin-api14"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
id = "api14"
|
||||
name = "API 1.4 host tests"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.4"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*", "on-event:file-closed", "on-event:settings-changed", "on-event:input-mode-changed"]
|
||||
|
||||
[capabilities]
|
||||
required = ["storage"]
|
||||
optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "ipc:$TMPDIR/api14-*"]
|
||||
|
||||
[[contributes.secrets]]
|
||||
name = "token"
|
||||
label = "Token"
|
||||
|
||||
[contributes.settings]
|
||||
schema = "settings.json"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "ipc"
|
||||
title = "ipc"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "fetch"
|
||||
title = "fetch"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "window-status"
|
||||
title = "window-status"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "remove-window-status"
|
||||
title = "remove-window-status"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "global-status"
|
||||
title = "global-status"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "execute"
|
||||
title = "execute"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "oauth"
|
||||
title = "oauth"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "secret"
|
||||
title = "secret"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "delete-secret"
|
||||
title = "delete-secret"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "undeclared-secret"
|
||||
title = "undeclared-secret"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "output"
|
||||
title = "output"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "events"
|
||||
title = "events"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "process-run"
|
||||
title = "process-run"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "process-run-command"
|
||||
title = "process-run-command"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "process-start"
|
||||
title = "process-start"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "terminal"
|
||||
title = "terminal"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "log"
|
||||
title = "log"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "open-editors"
|
||||
title = "open-editors"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "open-editor"
|
||||
title = "open-editor"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "set-selections"
|
||||
title = "set-selections"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "reveal"
|
||||
title = "reveal"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "search-text"
|
||||
title = "search-text"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "problems"
|
||||
title = "problems"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "decorations"
|
||||
title = "decorations"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "clear-decorations"
|
||||
title = "clear-decorations"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "file-decoration"
|
||||
title = "file-decoration"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "clear-file-decorations"
|
||||
title = "clear-file-decorations"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "vcs-log"
|
||||
title = "vcs-log"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "vcs-blame"
|
||||
title = "vcs-blame"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "vcs-diff"
|
||||
title = "vcs-diff"
|
||||
@@ -0,0 +1 @@
|
||||
{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":false}}}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
use openidea_plugin::{Error, Event, Invocation, Plugin, StatusItem, host, register_plugin};
|
||||
#[derive(Default)]
|
||||
struct Api14;
|
||||
impl Plugin for Api14 {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
let arg = arg.unwrap_or_default();
|
||||
match id {
|
||||
"open-editors" => Ok(Some(format!("{:?}", host::open_editors()?))),
|
||||
"open-editor" => {
|
||||
host::open_editor(arg, Some(openidea_plugin::Range::default()))?;
|
||||
Ok(None)
|
||||
}
|
||||
"set-selections" => {
|
||||
ctx.editor().unwrap().set_selections(&[openidea_plugin::Range::default()])?;
|
||||
Ok(None)
|
||||
}
|
||||
"reveal" => {
|
||||
ctx.editor().unwrap().reveal(openidea_plugin::Range::default())?;
|
||||
Ok(None)
|
||||
}
|
||||
"search-text" => {
|
||||
let result = ctx
|
||||
.project()
|
||||
.unwrap()
|
||||
.search_text(&openidea_plugin::TextQuery { pattern: arg.into(), ..Default::default() }, 10)?;
|
||||
Ok(Some(format!("{result:?}")))
|
||||
}
|
||||
"problems" => Ok(Some(format!("{:?}", host::problems(if arg.is_empty() { None } else { Some(arg) })?))),
|
||||
"decorations" => {
|
||||
host::set_decorations(
|
||||
arg,
|
||||
"test",
|
||||
&[openidea_plugin::Decoration {
|
||||
range: openidea_plugin::Range::default(),
|
||||
style: "highlight".into(),
|
||||
hover: Some("hover".into()),
|
||||
after: Some("after".into()),
|
||||
}],
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
"clear-decorations" => {
|
||||
host::clear_decorations("test")?;
|
||||
Ok(None)
|
||||
}
|
||||
"file-decoration" => {
|
||||
host::set_file_decoration(
|
||||
arg,
|
||||
"test",
|
||||
&openidea_plugin::FileDecoration { badge: Some("OK".into()), style: "added".into(), tooltip: None },
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
"clear-file-decorations" => {
|
||||
host::clear_file_decorations("test")?;
|
||||
Ok(None)
|
||||
}
|
||||
"vcs-log" => Ok(Some(format!(
|
||||
"{:?}",
|
||||
ctx.project().unwrap().vcs_log(if arg.is_empty() { None } else { Some(arg) }, 10)?
|
||||
))),
|
||||
"vcs-blame" => Ok(Some(format!("{:?}", ctx.project().unwrap().vcs_blame(arg)?))),
|
||||
"vcs-diff" => ctx.project().unwrap().vcs_diff(if arg.is_empty() { None } else { Some(arg) }, None),
|
||||
"ipc" => {
|
||||
let stream = host::ipc_connect(arg)?;
|
||||
stream.write(b"echo")?;
|
||||
let bytes = stream.read(4, 2000)?;
|
||||
stream.close()?;
|
||||
Ok(Some(String::from_utf8_lossy(&bytes).into()))
|
||||
}
|
||||
"fetch" => {
|
||||
let response = host::fetch(&openidea_plugin::HttpRequest::get(arg))?;
|
||||
Ok(Some(String::from_utf8_lossy(&response.body).into()))
|
||||
}
|
||||
"global-status" | "window-status" => {
|
||||
let item = StatusItem { text: arg.into(), tooltip: None, command: Some("events".into()) };
|
||||
if id == "global-status" {
|
||||
host::set_status_item("state", &item);
|
||||
} else {
|
||||
host::set_window_status_item("state", &item)?;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
"remove-window-status" => {
|
||||
host::remove_window_status_item("state")?;
|
||||
Ok(None)
|
||||
}
|
||||
"execute" => host::execute_command(arg, Some("argument")),
|
||||
"oauth" => {
|
||||
let redirect = host::begin_loopback("oauth/callback")?;
|
||||
host::output_append("oauth", &redirect.redirect_uri())?;
|
||||
redirect.wait(2000)
|
||||
}
|
||||
"secret" => {
|
||||
host::secret_set("token", arg)?;
|
||||
Ok(None)
|
||||
}
|
||||
"delete-secret" => {
|
||||
host::secret_delete("token")?;
|
||||
Ok(None)
|
||||
}
|
||||
"undeclared-secret" => {
|
||||
host::secret_set("undeclared", arg)?;
|
||||
Ok(None)
|
||||
}
|
||||
"output" => {
|
||||
host::output_append("test", arg)?;
|
||||
host::output_reveal("test")?;
|
||||
Ok(None)
|
||||
}
|
||||
"process-run" => Ok(Some(host::run_process("api14-tool", &[], None)?.stdout)),
|
||||
"process-run-command" => Ok(Some(host::run_command(&host::Command::new("api14-tool"), None, None)?.stdout)),
|
||||
"process-start" => {
|
||||
let child = host::start(&host::Command::new("api14-tool"), None)?;
|
||||
Ok(Some(child.wait(2000)?.stdout))
|
||||
}
|
||||
"terminal" => {
|
||||
host::open_terminal("API test", "api14-tool", &[])?;
|
||||
Ok(None)
|
||||
}
|
||||
"log" => {
|
||||
host::open_log_tab(
|
||||
"api-test",
|
||||
"API test",
|
||||
&openidea_plugin::details::ProcessLog {
|
||||
program: "api14-tool".into(),
|
||||
args: Vec::new(),
|
||||
toggles: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
"events" => host::storage::get("events"),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
fn handle_events(&mut self, events: &[Event], ctx: &Invocation) {
|
||||
let mut received: Vec<serde_json::Value> =
|
||||
host::storage::get("events").unwrap().map(|s| serde_json::from_str(&s).unwrap()).unwrap_or_default();
|
||||
for event in events {
|
||||
if let Event::Other { kind, data } = event {
|
||||
let editor = ctx.editor().map(|e| {
|
||||
(
|
||||
e.path(),
|
||||
e.selections()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|r| [r.start.line, r.start.character, r.end.line, r.end.character])
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
});
|
||||
received.push(serde_json::json!({ "kind": kind, "data": data, "editor": editor }));
|
||||
}
|
||||
}
|
||||
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
impl openidea_plugin::TestResults for Api14 {
|
||||
fn parse(&mut self, _config: &str, _chunk: &str) -> Vec<openidea_plugin::PluginTestEvent> {
|
||||
vec![]
|
||||
}
|
||||
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
|
||||
(config == "tests").then(|| tests.iter().map(|p| p.join("::")).collect())
|
||||
}
|
||||
}
|
||||
register_plugin!(Api14, test_results);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6d7d707c7492d487fbd9debae791ad7e3755ac005f1f518efa54e6536ec59f08
|
||||
f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# 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 = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-data-tools"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[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,21 @@
|
||||
[package]
|
||||
name = "openidea-plugin-data-tools"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"object","properties":{"text":{"type":"string"}},"additionalProperties":false}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9
|
||||
@@ -0,0 +1,68 @@
|
||||
id = "test.data-tools"
|
||||
name = "Data tools fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.4"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
[capabilities]
|
||||
required = ["project.read"]
|
||||
optional = ["database.sources"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "upsert"
|
||||
title = "upsert"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "remove"
|
||||
title = "remove"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "sources"
|
||||
title = "sources"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "credential"
|
||||
title = "credential"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "connect"
|
||||
title = "connect"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "stdio"
|
||||
title = "stdio"
|
||||
|
||||
[[contributes.mcpTools]]
|
||||
name = "echo"
|
||||
description = "Fixture echo"
|
||||
inputSchema = "arguments.json"
|
||||
readOnly = true
|
||||
destructive = false
|
||||
|
||||
[[contributes.mcpTools]]
|
||||
name = "write"
|
||||
description = "Fixture write"
|
||||
inputSchema = "arguments.json"
|
||||
readOnly = false
|
||||
destructive = false
|
||||
|
||||
[[contributes.mcpTools]]
|
||||
name = "erase"
|
||||
description = "Fixture erase"
|
||||
inputSchema = "arguments.json"
|
||||
readOnly = false
|
||||
destructive = true
|
||||
|
||||
[[contributes.mcpTools]]
|
||||
name = "spin"
|
||||
description = "Fixture spin"
|
||||
inputSchema = "arguments.json"
|
||||
readOnly = true
|
||||
destructive = false
|
||||
|
||||
[[contributes.mcpTools]]
|
||||
name = "denied"
|
||||
description = "Fixture denied"
|
||||
inputSchema = "arguments.json"
|
||||
readOnly = true
|
||||
destructive = false
|
||||
@@ -0,0 +1,39 @@
|
||||
use openidea_plugin::{Error, Invocation, McpProvider, Plugin, PluginDataSource, host, register_plugin};
|
||||
#[derive(Default)]
|
||||
struct DataTools;
|
||||
impl Plugin for DataTools {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id == "stdio" {
|
||||
return Ok(Some(serde_json::to_string(&host::mcp_stdio_command()).unwrap()));
|
||||
}
|
||||
let p = ctx.project().ok_or("project required")?;
|
||||
match id {
|
||||
"upsert" => {
|
||||
let source: PluginDataSource =
|
||||
serde_json::from_str(arg.unwrap()).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
p.upsert_data_source(&source)?;
|
||||
}
|
||||
"remove" => p.remove_data_source(arg.unwrap_or("local"))?,
|
||||
"sources" => return Ok(Some(serde_json::to_string(&p.data_sources()?).unwrap())),
|
||||
"credential" => p.set_data_source_credential("local", arg.unwrap_or("secret"))?,
|
||||
"connect" => p.connect_data_source(arg.unwrap_or("local"))?,
|
||||
_ => return Err(Error::NotFound(id.into())),
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
impl McpProvider for DataTools {
|
||||
fn call(&mut self, tool: &str, arguments: &str, ctx: &Invocation) -> Result<String, Error> {
|
||||
match tool {
|
||||
"spin" => loop {
|
||||
std::hint::spin_loop();
|
||||
},
|
||||
"denied" => {
|
||||
host::run_process("sh", &[], None)?;
|
||||
Ok("unexpected".into())
|
||||
}
|
||||
_ => Ok(format!("{}:{}:{}", tool, ctx.project().ok_or("project required")?.root()?, arguments)),
|
||||
}
|
||||
}
|
||||
}
|
||||
register_plugin!(DataTools, mcp);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
652977e0bf63bc7664807016998a4d4795d2a7eb841ee1912ef562057e5437cb
|
||||
f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
42d3856b634bdaa69e21f4a42a9f375b73c2d19753a8986b82f2228bf92d37bc
|
||||
f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
cacd381d84e1070fffe8f0ac64fad91b768df63b759c0b3e5e2d22a6a2fdeb73
|
||||
a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# 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 = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-execution"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[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,21 @@
|
||||
[package]
|
||||
name = "openidea-plugin-execution"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
Loaded 100 of 414 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user