mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-12 18:36:53 -10:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff19e2ffa | ||
|
|
dbbc70d71b | ||
|
|
03073d83e5 | ||
|
|
2dda0be909 | ||
|
|
46da4e8f5f | ||
|
|
39e884a07e | ||
|
|
129f0cc0d0 | ||
|
|
adc252ccd4 | ||
|
|
7a9baac475 | ||
|
|
20fe72de09 | ||
|
|
d7edf8bded | ||
|
|
8dd29bad41 | ||
|
|
75565927d8 | ||
|
|
9a76d1766d | ||
|
|
02ff61e6a7 | ||
|
|
fd82beb309 | ||
|
|
ccd1f3aca1 | ||
|
|
1dbebefa94 | ||
|
|
064d4506cd | ||
|
|
95047cefb3 | ||
|
|
e0982cc51d | ||
|
|
e1be533bd2 | ||
|
|
299fb14f80 | ||
|
|
a41fe86d47 | ||
|
|
7579ecbbe1 | ||
|
|
3dae4ef729 | ||
|
|
067feac89c | ||
|
|
a7a00da263 | ||
|
|
9f9a2d8cf0 | ||
|
|
43bcd66b2b | ||
|
|
004da36bed | ||
|
|
62a39dadb7 | ||
|
|
ea8e04e448 | ||
|
|
b9d18250f0 | ||
|
|
29b2b1a272 | ||
|
|
1a06f6e9cd | ||
|
|
6f9279e753 | ||
|
|
75607c0bbf | ||
|
|
c133d2dba9 | ||
|
|
0648856359 | ||
|
|
32f5ef20a0 | ||
|
|
25cd480c30 | ||
|
|
8427e48975 | ||
|
|
4139cb64c2 | ||
|
|
a73e3c21ad | ||
|
|
6d8e41c2c1 | ||
|
|
cad33ef119 | ||
|
|
5c3feea2a8 | ||
|
|
80d438098c | ||
|
|
68782a7220 | ||
|
|
cb6bcd2fa2 | ||
|
|
a243c255fe | ||
|
|
92b5d2dafd | ||
|
|
6bf1794ed6 |
No files matched your search
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"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{
|
||||
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
|
||||
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
|
||||
AgentUsage,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -145,15 +146,22 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
|
||||
}
|
||||
|
||||
/// `available_commands_update`.
|
||||
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
|
||||
update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("name")?.as_str()?.trim_start_matches('/');
|
||||
let source = if provider == "codex-acp" && name.starts_with('$') {
|
||||
AgentCommandSource::Skill
|
||||
} else {
|
||||
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
|
||||
};
|
||||
Some(AgentCommand {
|
||||
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
|
||||
name: name.to_owned(),
|
||||
source,
|
||||
description: str_of(c, "description").unwrap_or_default(),
|
||||
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
})
|
||||
@@ -325,12 +333,39 @@ mod tests {
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sources_in_updates_and_stored_tasks() {
|
||||
let update = json!({"availableCommands": [
|
||||
{"name": "review", "source": "skill"},
|
||||
{"name": "$lint"},
|
||||
{"name": "compact"}
|
||||
]});
|
||||
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
sources("codex-acp"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
|
||||
);
|
||||
assert_eq!(
|
||||
sources("custom"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
|
||||
);
|
||||
let old: AgentCommand =
|
||||
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
|
||||
assert_eq!(old.source, AgentCommandSource::Command);
|
||||
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
|
||||
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}));
|
||||
let cmds = commands_of(
|
||||
&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}),
|
||||
"claude",
|
||||
);
|
||||
assert_eq!(cmds[0].source, AgentCommandSource::Command);
|
||||
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
|
||||
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
|
||||
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
|
||||
|
||||
@@ -100,6 +100,8 @@ 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.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
}
|
||||
@@ -151,6 +153,40 @@ 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) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
.entry(spec.id.clone())
|
||||
.or_insert_with(|| {
|
||||
let (done, ready) = tokio::sync::watch::channel(false);
|
||||
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
|
||||
{
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if shared.known_commands(&spec.id).is_empty() {
|
||||
shared.remember(&spec.id, None, Some(&commands), None);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let _ = done.send(true);
|
||||
});
|
||||
ready
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
while !*ready.borrow_and_update() {
|
||||
if ready.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -317,6 +353,7 @@ impl AgentManager {
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
command_fetches: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
});
|
||||
let mut tasks = HashMap::new();
|
||||
@@ -420,9 +457,21 @@ impl AgentManager {
|
||||
// Commands the agent implements itself and never lists (its CLI builtins):
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& offered.is_empty()
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_commands(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
|
||||
offered.push(AgentCommand {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
});
|
||||
}
|
||||
}
|
||||
if provider != "claude" && provider != "claude-acp" {
|
||||
@@ -1712,7 +1761,7 @@ impl Task {
|
||||
true
|
||||
}
|
||||
"available_commands_update" => {
|
||||
s.task.commands = config::commands_of(update);
|
||||
s.task.commands = config::commands_of(update, &provider);
|
||||
true
|
||||
}
|
||||
"usage_update" => {
|
||||
|
||||
@@ -156,6 +156,14 @@ pub struct AgentConfigOption {
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentCommandSource {
|
||||
#[default]
|
||||
Command,
|
||||
Skill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommand {
|
||||
@@ -164,6 +172,8 @@ pub struct AgentCommand {
|
||||
pub description: String,
|
||||
/// What to type after the command, when it takes input.
|
||||
pub hint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: AgentCommandSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -69,6 +69,38 @@ 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> {
|
||||
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())?;
|
||||
let mut args = spec.args;
|
||||
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
|
||||
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())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
|
||||
@@ -53,6 +53,7 @@ fn attach(
|
||||
client: OnceLock::new(),
|
||||
state: Mutex::new(State::default()),
|
||||
models: tokio::sync::Mutex::new(None),
|
||||
skills: tokio::sync::Mutex::new(()),
|
||||
});
|
||||
let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?;
|
||||
let _ = shim.codex.set(codex.clone());
|
||||
@@ -82,6 +83,8 @@ struct CodexShim {
|
||||
state: Mutex<State>,
|
||||
/// `model/list`, fetched once.
|
||||
models: tokio::sync::Mutex<Option<Arc<Vec<ModelInfo>>>>,
|
||||
/// Keep skill snapshots in request order, including invalidations during session setup.
|
||||
skills: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -300,6 +303,20 @@ impl CodexShim {
|
||||
models
|
||||
}
|
||||
|
||||
async fn publish_skills(&self, session: &str) {
|
||||
let _listing = self.skills.lock().await;
|
||||
let cwd = self.state.lock().threads.get(session).and_then(|t| t.cwd.clone());
|
||||
let Some(cwd) = cwd else { return };
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(5), self.call("skills/list", json!({ "cwds": [cwd] }))).await;
|
||||
if let Ok(Ok(result)) = result
|
||||
&& let Some(update) = translate::skill_commands(&result)
|
||||
&& self.state.lock().threads.contains_key(session)
|
||||
{
|
||||
self.emit(session, update);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a thread codex opened for a session of its own; returns its `configOptions`.
|
||||
fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option<Settings>) -> (String, Value) {
|
||||
let thread = result.get("thread").unwrap_or(&Value::Null);
|
||||
@@ -326,6 +343,7 @@ impl CodexShim {
|
||||
if id.is_empty() {
|
||||
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
|
||||
}
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -344,6 +362,7 @@ impl CodexShim {
|
||||
for update in translate::history(thread, cwd.as_deref()) {
|
||||
self.emit(id, update);
|
||||
}
|
||||
self.publish_skills(id).await;
|
||||
Ok(json!({ "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -362,6 +381,7 @@ impl CodexShim {
|
||||
}
|
||||
let result = self.call("thread/fork", request).await?;
|
||||
let (id, options) = self.open_thread(&result, &models, settings);
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -551,9 +571,25 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_codex_notification(&self, method: &str, params: &Value) {
|
||||
fn on_codex_notification(self: &Arc<Self>, method: &str, params: &Value) {
|
||||
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default();
|
||||
match method {
|
||||
"skills/changed" => {
|
||||
let shim = self.clone();
|
||||
let sessions: Vec<String> = self
|
||||
.state
|
||||
.lock()
|
||||
.threads
|
||||
.iter()
|
||||
.filter(|(_, t)| t.settings.is_some() && t.parent.is_none())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
self.runtime.spawn(async move {
|
||||
for session in sessions {
|
||||
shim.publish_skills(&session).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"turn/started" => {
|
||||
if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) {
|
||||
self.turn_known(thread, turn, true);
|
||||
|
||||
@@ -528,12 +528,21 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
let mut sent = Vec::new();
|
||||
for (out, frame) in &t.frames {
|
||||
if *out {
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
let actual: Value = loop {
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
// Older recordings predate skill discovery; play an unsupported server.
|
||||
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
|
||||
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
continue;
|
||||
}
|
||||
break actual;
|
||||
};
|
||||
match frame.get("method") {
|
||||
Some(method) => {
|
||||
assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}");
|
||||
@@ -1056,3 +1065,117 @@ fn session_mcp_servers_become_thread_configuration() {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn skills_are_listed_for_sessions_and_refreshed_on_change() {
|
||||
for (method, native) in
|
||||
[("session/new", "thread/start"), ("session/load", "thread/resume"), ("session/fork", "thread/fork")]
|
||||
{
|
||||
for initial in [
|
||||
json!({"result": {"data": [{"cwd": "/work", "errors": [], "skills": [
|
||||
{"name": "review", "enabled": true, "description": "Review", "path": "/work/SKILL.md", "scope": "repo"}
|
||||
]}]}}),
|
||||
json!({"error": {"code": -32601, "message": "unsupported"}}),
|
||||
json!({"result": {}}),
|
||||
] {
|
||||
let listed = initial.pointer("/result/data").is_some();
|
||||
let mut initial = initial;
|
||||
initial["id"] = json!(4);
|
||||
let transcript = Transcript {
|
||||
meta: Value::Null,
|
||||
frames: vec![
|
||||
(true, json!({"id": 1, "method": "initialize"})),
|
||||
(false, json!({"id": 1, "result": {}})),
|
||||
(true, json!({"method": "initialized"})),
|
||||
(true, json!({"id": 2, "method": "model/list"})),
|
||||
(false, json!({"id": 2, "result": {"data": []}})),
|
||||
(true, json!({"id": 3, "method": native})),
|
||||
(
|
||||
false,
|
||||
json!({"id": 3, "result": {"thread": {"id": "s", "cwd": "/work", "turns": []}, "cwd": "/work"}}),
|
||||
),
|
||||
(true, json!({"id": 4, "method": "skills/list"})),
|
||||
(false, initial),
|
||||
(true, json!({"id": 5, "method": "turn/start"})),
|
||||
(false, json!({"id": 5, "error": {"code": -32602, "message": "no model in this test"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 6, "method": "skills/list"})),
|
||||
(false, json!({"id": 6, "result": {"data": [{"cwd": "/work", "skills": [], "errors": []}]}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 7, "method": "skills/list"})),
|
||||
(false, json!({"id": 7, "error": {"code": -32603, "message": "listing failed"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 8, "method": "skills/list"})),
|
||||
(false, json!({"id": 8, "result": {"data": []}})),
|
||||
],
|
||||
};
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let client = Arc::new(Client::default());
|
||||
let acp = attach(client.clone(), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
request(&acp, "initialize", json!({})).await.unwrap();
|
||||
request(&acp, method, json!({"sessionId": "s", "cwd": "/work"})).await.unwrap();
|
||||
let initial_updates = client.of("s");
|
||||
assert_eq!(initial_updates.len(), usize::from(listed), "{method}");
|
||||
if listed {
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["name"], "$review");
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["source"], "skill");
|
||||
}
|
||||
assert!(
|
||||
request(&acp, "session/prompt", json!({"sessionId": "s", "prompt": text("/$review check this")}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let (frames, _read, _write) = player.await.unwrap();
|
||||
for frame in sent(&frames, "skills/list") {
|
||||
assert_eq!(frame["params"], json!({"cwds": ["/work"]}));
|
||||
}
|
||||
assert_eq!(
|
||||
sent(&frames, "turn/start")[0]["params"]["input"],
|
||||
json!([{"type": "text", "text": "/$review check this"}])
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while client.of("s").len() < usize::from(listed) + 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let updates = client.of("s");
|
||||
assert_eq!(updates.len(), usize::from(listed) + 2);
|
||||
assert_eq!(updates.last().unwrap()["availableCommands"], json!([]));
|
||||
acp.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_become_commands_with_their_invocation_names() {
|
||||
let update = skill_commands(&json!({"data": [{"skills": [
|
||||
{"name": "review", "enabled": true, "description": "Long", "shortDescription": "Short"},
|
||||
{"name": "review", "enabled": true, "description": "Duplicate"},
|
||||
{"name": "hidden", "enabled": false, "description": "Disabled"},
|
||||
{"name": "modern", "enabled": true, "description": "Long", "interface": {"shortDescription": "Modern"}},
|
||||
{"name": "full", "enabled": true, "description": "Full description"},
|
||||
{"name": "", "enabled": true}, {"description": "No name"}
|
||||
]}]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
update,
|
||||
json!({"sessionUpdate": "available_commands_update", "availableCommands": [
|
||||
{"name": "$review", "description": "Short", "source": "skill", "input": null},
|
||||
{"name": "$modern", "description": "Modern", "source": "skill", "input": null},
|
||||
{"name": "$full", "description": "Full description", "source": "skill", "input": null}
|
||||
]})
|
||||
);
|
||||
let commands = crate::config::commands_of(&update, "codex");
|
||||
assert!(commands.iter().all(|c| c.source == crate::AgentCommandSource::Skill));
|
||||
assert_eq!(commands[0].name, "$review");
|
||||
assert!(skill_commands(&json!({"data": []})).unwrap()["availableCommands"].as_array().unwrap().is_empty());
|
||||
assert!(skill_commands(&json!({})).is_none());
|
||||
}
|
||||
@@ -256,6 +256,31 @@ fn effort_name(effort: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The enabled skills from `skills/list`, named as Codex ACP advertises them.
|
||||
pub(super) fn skill_commands(result: &Value) -> Option<Value> {
|
||||
let entries = result.get("data")?.as_array()?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let commands: Vec<Value> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("skills").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|skill| {
|
||||
let name = skill.get("name")?.as_str()?.trim();
|
||||
if name.is_empty() || skill.get("enabled") == Some(&Value::Bool(false)) || !seen.insert(name) {
|
||||
return None;
|
||||
}
|
||||
let description = skill
|
||||
.pointer("/interface/shortDescription")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| skill.get("shortDescription").and_then(Value::as_str))
|
||||
.or_else(|| skill.get("description").and_then(Value::as_str))
|
||||
.unwrap_or(name);
|
||||
Some(json!({ "name": format!("${name}"), "description": description, "source": "skill", "input": null }))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }))
|
||||
}
|
||||
|
||||
/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text.
|
||||
pub(super) fn user_input(prompt: &Value) -> Vec<Value> {
|
||||
let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
|
||||
//! of the working directory and every directory above it, then the user's `~/.claude`.
|
||||
|
||||
use crate::AgentCommand;
|
||||
use crate::{AgentCommand, AgentCommandSource};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -36,9 +36,14 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
|
||||
|
||||
/// `first` (the agent's own list) plus the commands of `more` it lacks.
|
||||
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
|
||||
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
|
||||
let mut out = first;
|
||||
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
|
||||
for c in more {
|
||||
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
|
||||
offered.source = c.source;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
|
||||
return None;
|
||||
}
|
||||
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
|
||||
Some(meta.command(name, &text))
|
||||
Some(meta.command(name, &text, AgentCommandSource::Skill))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -79,7 +84,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
|
||||
} else if let Some(stem) = name.strip_suffix(".md")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
{
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +152,7 @@ impl Frontmatter {
|
||||
}
|
||||
|
||||
/// `description`, else the body's first line.
|
||||
fn command(&self, name: String, text: &str) -> AgentCommand {
|
||||
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
|
||||
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
|
||||
let body = text.get(self.body..).unwrap_or_default();
|
||||
body.lines()
|
||||
@@ -158,6 +163,7 @@ impl Frontmatter {
|
||||
});
|
||||
AgentCommand {
|
||||
name,
|
||||
source,
|
||||
description: truncate(&description),
|
||||
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
}
|
||||
@@ -194,13 +200,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cmd(name: &str, description: &str) -> AgentCommand {
|
||||
AgentCommand { name: name.into(), description: description.into(), hint: None }
|
||||
AgentCommand {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_user_skills_and_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
// Home is an ancestor of the project, so the walk stops there instead of climbing
|
||||
// past it into the real profile (a temp dir under the user's home holds its skills).
|
||||
let home = tmp.path().to_path_buf();
|
||||
let repo = tmp.path().join("work").join("idea");
|
||||
let skills = repo.join(".claude/skills");
|
||||
write(
|
||||
@@ -227,6 +240,12 @@ mod tests {
|
||||
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
|
||||
);
|
||||
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
|
||||
for name in ["issue-fix", "triage", "verify", "lint"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Skill);
|
||||
}
|
||||
for name in ["git:commit", "review", "parent", "brooks-review"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Command);
|
||||
}
|
||||
assert_eq!(by("triage").description, "One pass over open issues and PRs");
|
||||
assert_eq!(by("issue-fix").description, "Fix an issue");
|
||||
assert_eq!(by("verify").description, "Build and drive the app");
|
||||
@@ -262,10 +281,14 @@ mod tests {
|
||||
fn the_agents_entries_win() {
|
||||
let merged = merge(
|
||||
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
|
||||
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
|
||||
vec![
|
||||
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
|
||||
cmd("verify", "Verify"),
|
||||
],
|
||||
);
|
||||
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
|
||||
assert_eq!(merged[0].description, "from the agent");
|
||||
assert_eq!(merged[0].source, AgentCommandSource::Skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -339,6 +339,9 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
}
|
||||
|
||||
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
|
||||
let agents = dir.join("agents");
|
||||
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
|
||||
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
@@ -353,14 +356,97 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
store_dir: dir.join("store"),
|
||||
worktrees_dir: dir.join("worktrees"),
|
||||
models_file: models_file.to_path_buf(),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
custom_agents_dir: Some(agents),
|
||||
// The project is the boundary of the skill walk: nothing above it (such as the
|
||||
// user's profile, when temp lives under it) leaks into the test.
|
||||
home_dir: Some(dir.to_path_buf()),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
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"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let script = root.join("fake-claude");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys, time
|
||||
root = pathlib.Path.cwd()
|
||||
with (root / 'launches').open('a') as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + '\n')
|
||||
request = json.loads(sys.stdin.readline())
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(json.dumps(request) + '\n')
|
||||
outcome = os.environ['OUTCOME']
|
||||
if outcome == 'exit':
|
||||
sys.exit(1)
|
||||
if outcome == 'timeout':
|
||||
time.sleep(60)
|
||||
response = {'subtype': outcome, 'request_id': request['request_id']}
|
||||
if outcome == 'success':
|
||||
response['response'] = {'commands': [
|
||||
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
|
||||
else:
|
||||
response['error'] = 'Authentication required'
|
||||
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)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::create_dir_all(root.join(".claude/skills/triage")).unwrap();
|
||||
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
|
||||
.unwrap();
|
||||
let mgr = manager(root, Arc::new(FakeAgent::default()));
|
||||
{
|
||||
let mut providers = mgr.shared.providers.write();
|
||||
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
|
||||
if outcome != "missing" {
|
||||
spec.command = script.to_string_lossy().into_owned();
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
|
||||
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" {
|
||||
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());
|
||||
} else {
|
||||
assert_eq!(names, ["triage"], "{outcome}");
|
||||
assert!(!root.join("models.json").exists());
|
||||
}
|
||||
assert!(mgr.tasks.read().is_empty());
|
||||
assert!(!root.join("store").exists());
|
||||
if outcome != "missing" {
|
||||
let launches = std::fs::read_to_string(root.join("launches")).unwrap();
|
||||
assert_eq!(launches.lines().count(), 1, "{outcome}");
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
|
||||
for auth_on_new in [true, false] {
|
||||
@@ -575,6 +661,7 @@ async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metada
|
||||
name: "compact".into(),
|
||||
description: "Agent supplied description".into(),
|
||||
hint: Some("instructions".into()),
|
||||
source: AgentCommandSource::Command,
|
||||
};
|
||||
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
|
||||
// The task's provider wins over the caller's provider; metadata is not replaced.
|
||||
@@ -1854,6 +1941,7 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
};
|
||||
git(&["init", "-q", "-b", "member-main"]);
|
||||
git(&["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(member.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
@@ -2167,6 +2255,7 @@ async fn delete_running_agent(no_load: bool) {
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
git(&["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
|
||||
@@ -95,7 +95,7 @@ pub fn launch_fingerprint(
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
|
||||
@@ -772,13 +772,7 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat
|
||||
has_non_java_sources(module)
|
||||
}
|
||||
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
|
||||
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
|
||||
// JDT may have left newer error stubs in Maven's output directories.
|
||||
BuildKind::Rebuild
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
|
||||
let mut plan = plan(root, model, kind, Some(module), None)?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
@@ -790,6 +784,12 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> Id
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LaunchBuild {
|
||||
pub class_dirs: Vec<PathBuf>,
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
/// Compiles the module at `module` with its build system before a JVM launch and
|
||||
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
|
||||
pub fn build_for_launch(
|
||||
@@ -797,11 +797,12 @@ pub fn build_for_launch(
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
kind: BuildKind,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
) -> IdeResult<LaunchBuild> {
|
||||
let plan = launch_plan(root, model, module, test, kind)?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
if !result.success {
|
||||
let failure = if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
.messages
|
||||
.iter()
|
||||
@@ -812,17 +813,21 @@ pub fn build_for_launch(
|
||||
_ => m.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
|
||||
}
|
||||
if plan.system == BuildSystem::Maven
|
||||
Some(format!("Build failed:\n{}", errors.join("\n")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let class_dirs = if plan.system == BuildSystem::Maven
|
||||
&& let Some(m) = &model.maven
|
||||
{
|
||||
return Ok(maven_launch_projects(root, m, module, test)
|
||||
maven_launch_projects(root, m, module, test)
|
||||
.iter()
|
||||
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
|
||||
.collect());
|
||||
}
|
||||
Ok(launch_class_dirs(plan.system, module, test))
|
||||
.collect()
|
||||
} else {
|
||||
launch_class_dirs(plan.system, module, test)
|
||||
};
|
||||
Ok(LaunchBuild { class_dirs, failure })
|
||||
}
|
||||
|
||||
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
|
||||
@@ -1309,13 +1314,17 @@ mod tests {
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
let model = reactor_model();
|
||||
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
|
||||
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
|
||||
let step = &plan.steps[0];
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
|
||||
// Rebuild cleans first, as IDEA's Rebuild does.
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
|
||||
assert_eq!(
|
||||
step.args,
|
||||
plan.steps[0].args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
);
|
||||
}
|
||||
@@ -1337,9 +1346,10 @@ mod tests {
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = reactor_model();
|
||||
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
dirs.class_dirs,
|
||||
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
|
||||
.map(|p| root.join(p))
|
||||
);
|
||||
@@ -1348,12 +1358,16 @@ mod tests {
|
||||
"mvnw",
|
||||
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
|
||||
);
|
||||
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
|
||||
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
|
||||
let error =
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.failure
|
||||
.unwrap();
|
||||
assert!(error.contains("Kotlin compilation failed"), "{error}");
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn load_linked(
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
@@ -222,6 +222,30 @@ mod tests {
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join("extra")).unwrap();
|
||||
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cache = root.join("cache");
|
||||
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
|
||||
let modules = |settings: &ide_core::settings::MavenSettings| {
|
||||
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
|
||||
};
|
||||
assert!(modules(&Default::default()).is_empty());
|
||||
assert_eq!(modules(&settings), ["extra/pom.xml"]);
|
||||
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
|
||||
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
|
||||
assert!(modules(&settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -71,6 +71,9 @@ pub struct MavenModel {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MavenProject {
|
||||
/// Owning project folder when displayed in a workspace.
|
||||
#[specta(optional)]
|
||||
pub project_root: Option<String>,
|
||||
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
|
||||
pub pom: String,
|
||||
/// Project-relative directory; `""` = the project root.
|
||||
@@ -169,6 +172,9 @@ pub enum Omission {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MavenProfile {
|
||||
/// Owning project folder when displayed in a workspace.
|
||||
#[specta(optional)]
|
||||
pub project_root: Option<String>,
|
||||
pub id: String,
|
||||
pub active_by_default: bool,
|
||||
}
|
||||
@@ -503,7 +509,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
continue;
|
||||
}
|
||||
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
|
||||
}
|
||||
profiles
|
||||
}
|
||||
@@ -1063,7 +1069,7 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
|
||||
/// [`load`] with the POMs `settings` link (Add Maven Projects).
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
|
||||
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
|
||||
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
@@ -1212,11 +1218,12 @@ pub fn load_with_linked(
|
||||
// The project can activate the profiles of its own POM and its ancestors'.
|
||||
for (id, active) in &ctx.profiles {
|
||||
if !model.profiles.iter().any(|x| x.id == *id) {
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
|
||||
}
|
||||
}
|
||||
let artifact_id = i(&pom.artifact_id);
|
||||
model.projects.push(MavenProject {
|
||||
project_root: None,
|
||||
pom: rel_path(root, &path),
|
||||
dir: rel_path(root, &dir),
|
||||
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
|
||||
@@ -1836,7 +1843,7 @@ mod tests {
|
||||
</settings>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
|
||||
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
|
||||
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ pub fn sync_maven(
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.profiles,
|
||||
&options.maven_settings.profiles(&options.profiles),
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
|
||||
@@ -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"] }
|
||||
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"] }
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Bounded, ordered process input. Only the worker touches the OS writer.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WRITES: usize = 1024;
|
||||
const RETRY: Duration = Duration::from_millis(4);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
queue: VecDeque<Vec<u8>>,
|
||||
bytes: usize,
|
||||
finished: bool,
|
||||
aborted: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
|
||||
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
|
||||
/// without joining the worker; `finish` instead drains accepted input before EOF.
|
||||
pub struct InputWriter {
|
||||
shared: Arc<(Mutex<State>, Condvar)>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// Unix writers must first be made nonblocking with `nonblocking`.
|
||||
pub fn new(
|
||||
name: String,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
on_error: impl FnOnce(io::Error) + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new().name(name).spawn(move || {
|
||||
let (lock, wake) = &*worker_shared;
|
||||
let result = (|| {
|
||||
loop {
|
||||
let data = {
|
||||
let mut state = lock.lock().unwrap();
|
||||
while state.queue.is_empty() && !state.finished && !state.aborted {
|
||||
state = wake.wait(state).unwrap();
|
||||
}
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(data) = state.queue.pop_front() else { return Ok(()) };
|
||||
data
|
||||
};
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
if lock.lock().unwrap().aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let flushing = offset == data.len();
|
||||
let result = if !flushing {
|
||||
writer.write(&data[offset..]).and_then(|n| {
|
||||
if n == 0 {
|
||||
Err(io::ErrorKind::WriteZero.into())
|
||||
} else {
|
||||
offset += n;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
writer.flush()
|
||||
};
|
||||
match result {
|
||||
Ok(()) if flushing => break,
|
||||
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(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
let len = data.len();
|
||||
drop(data);
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.bytes = state.bytes.saturating_sub(len);
|
||||
}
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
let mut state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return;
|
||||
}
|
||||
state.error = Some(e.to_string());
|
||||
state.queue.clear();
|
||||
state.bytes = 0;
|
||||
drop(state);
|
||||
on_error(e);
|
||||
}
|
||||
})?;
|
||||
Ok(Self { shared, worker: Some(worker) })
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &[u8]) -> io::Result<()> {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
if let Some(error) = &state.error {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
|
||||
}
|
||||
if state.finished || state.aborted {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
|
||||
));
|
||||
}
|
||||
state.bytes += data.len();
|
||||
state.queue.push_back(data.to_vec());
|
||||
wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send EOF after the accepted writes, without waiting for the child to read.
|
||||
pub fn finish(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
lock.lock().unwrap().finished = true;
|
||||
wake.notify_one();
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.aborted = true;
|
||||
state.queue.clear();
|
||||
wake.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputWriter {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
let Some(worker) = self.worker.take() else { return };
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
// A cancellation can race with entry into WriteFile, so retry off
|
||||
// the caller's thread until the worker observes the closed queue.
|
||||
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
|
||||
while !worker.is_finished() {
|
||||
unsafe {
|
||||
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
|
||||
}
|
||||
std::thread::sleep(RETRY);
|
||||
}
|
||||
let _ = worker.join();
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
drop(worker);
|
||||
}
|
||||
}
|
||||
|
||||
/// PTY clones share file status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
#[cfg(unix)]
|
||||
use std::time::Instant;
|
||||
|
||||
struct SlowWriter {
|
||||
ready: Arc<AtomicBool>,
|
||||
output: Arc<Mutex<Vec<u8>>>,
|
||||
dropped: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl Write for SlowWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if !self.ready.load(Ordering::Acquire) {
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
let n = bytes.len().min(7);
|
||||
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SlowWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.dropped.send(());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
std::thread::sleep(RETRY * 2);
|
||||
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
writer.close();
|
||||
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
|
||||
assert!(output.lock().unwrap().is_empty());
|
||||
assert!(writer.write(b"closed").is_err());
|
||||
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let paste = "abé".repeat(1024);
|
||||
writer.write(paste.as_bytes()).unwrap();
|
||||
writer.write(b"tail").unwrap();
|
||||
writer.finish();
|
||||
assert!(writer.write(b"after EOF").is_err());
|
||||
ready.store(true, Ordering::Release);
|
||||
rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_writes_are_also_bounded() {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let writer = InputWriter { shared, worker: None };
|
||||
for _ in 0..MAX_WRITES {
|
||||
writer.write(b"x").unwrap();
|
||||
}
|
||||
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
}
|
||||
|
||||
#[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();
|
||||
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(!writer.worker.as_ref().unwrap().is_finished());
|
||||
let started = Instant::now();
|
||||
writer.close();
|
||||
writer.worker.take().unwrap().join().unwrap();
|
||||
assert!(started.elapsed() < Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod event;
|
||||
pub mod go;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod input;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -386,6 +386,8 @@ pub struct AppearanceSettings {
|
||||
pub menu_icons: bool,
|
||||
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
|
||||
pub main_menu_style: String,
|
||||
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
|
||||
pub main_menu_alt: bool,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
@@ -393,6 +395,9 @@ pub struct AppearanceSettings {
|
||||
pub show_tool_window_names: bool,
|
||||
pub show_tool_window_badges: bool,
|
||||
pub widescreen_tool_windows: bool,
|
||||
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
|
||||
pub show_main_toolbar: bool,
|
||||
pub show_status_bar: bool,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
@@ -456,12 +461,15 @@ impl Default for AppearanceSettings {
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
main_menu_alt: true,
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
show_tool_window_names: false,
|
||||
show_tool_window_badges: true,
|
||||
widescreen_tool_windows: false,
|
||||
show_main_toolbar: true,
|
||||
show_status_bar: true,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
@@ -621,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,
|
||||
@@ -638,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(),
|
||||
@@ -759,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,
|
||||
@@ -786,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,
|
||||
}
|
||||
@@ -926,6 +941,8 @@ pub struct MavenSettings {
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
pub user_settings: String,
|
||||
/// Toolchains file (`-t`); empty = `~/.m2/toolchains.xml`.
|
||||
pub toolchains: String,
|
||||
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
|
||||
pub local_repository: String,
|
||||
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
|
||||
@@ -937,9 +954,25 @@ pub struct MavenSettings {
|
||||
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
|
||||
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
|
||||
pub threads: String,
|
||||
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
|
||||
pub active_profiles: String,
|
||||
}
|
||||
|
||||
impl MavenSettings {
|
||||
/// Settings profiles followed by the Maven tool window's choices.
|
||||
pub fn profiles(&self, choices: &[String]) -> Vec<String> {
|
||||
let mut profiles = Vec::new();
|
||||
for profile in self.active_profiles.split(',').chain(choices.iter().map(String::as_str)).map(str::trim) {
|
||||
if !profile.is_empty() && !profiles.iter().any(|p| p == profile) {
|
||||
profiles.push(profile.to_owned());
|
||||
}
|
||||
}
|
||||
let excluded: Vec<String> =
|
||||
profiles.iter().filter_map(|p| p.strip_prefix(['!', '-']).map(str::to_owned)).collect();
|
||||
profiles.retain(|p| !excluded.contains(p));
|
||||
profiles
|
||||
}
|
||||
|
||||
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
|
||||
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
|
||||
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
|
||||
@@ -1230,9 +1263,13 @@ mod tests {
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
assert!(old.appearance.main_menu_alt);
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
|
||||
assert!(!off.appearance.main_menu_alt);
|
||||
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1399,6 +1436,24 @@ mod maven_linkage_tests {
|
||||
assert_eq!(threads("2..5C").as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_combine_settings_and_tool_window_choices() {
|
||||
let settings = MavenSettings { active_profiles: " a, b, ,a ".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["!b".into(), "c".into(), "a".into()]), ["a", "!b", "c"]);
|
||||
let settings = MavenSettings { active_profiles: "!a".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["a".into()]), ["!a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_and_toolchains_round_trip_and_default_to_empty() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.toolchains.is_empty() && old.active_profiles.is_empty());
|
||||
let settings =
|
||||
MavenSettings { toolchains: "config files/toolchains.xml".into(), active_profiles: "a,b,!c".into(), ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_count_round_trips_and_defaults_to_empty() {
|
||||
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,4 @@ libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
|
||||
@@ -101,6 +101,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
}
|
||||
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
|
||||
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
|
||||
"java-debug" => cfg(
|
||||
"com.microsoft.java.debug.plugin.jar",
|
||||
@@ -373,8 +374,8 @@ pub struct AdapterIo {
|
||||
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
|
||||
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
|
||||
|
||||
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
|
||||
const SHARES_STDIO: &str = "delve";
|
||||
/// These debuggees inherit adapter stdio; no DAP event carries their output.
|
||||
const SHARES_STDIO: &[&str] = &["delve", "rdbg"];
|
||||
const DELVE_BANNER: &str = "DAP server listening at:";
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
|
||||
@@ -476,6 +477,9 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
if spec.adapter == "rdbg" {
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
}
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
@@ -485,7 +489,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
|
||||
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
@@ -515,6 +519,11 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if spec.adapter == "rdbg"
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
|
||||
+84
-11
@@ -282,7 +282,6 @@ impl SessionOwner {
|
||||
}
|
||||
|
||||
const INIT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Redefining many classes in a large JVM can take a while.
|
||||
@@ -305,6 +304,63 @@ pub struct DebugManager {
|
||||
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
|
||||
}
|
||||
|
||||
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
|
||||
/// arguments and environment: they go on its command line after `--` and into its process.
|
||||
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
|
||||
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
|
||||
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
|
||||
if config.request != "launch" {
|
||||
return Ok(());
|
||||
}
|
||||
if spec.command == "rdbg"
|
||||
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
|
||||
{
|
||||
if !bundles_debug(&gemfile) {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
|
||||
gemfile.display()
|
||||
)));
|
||||
}
|
||||
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
|
||||
spec.command = "bundle".into();
|
||||
}
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
spec.args.push("--".into());
|
||||
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
|
||||
// without arguments go through -S so spaces and metacharacters stay literal.
|
||||
if config.program.is_some() && config.args.is_empty() {
|
||||
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
|
||||
}
|
||||
spec.args.extend(config.program.iter().cloned());
|
||||
spec.args.extend(config.args.iter().cloned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
|
||||
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
|
||||
let gemfile = env
|
||||
.get("BUNDLE_GEMFILE")
|
||||
.cloned()
|
||||
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
|
||||
match gemfile.filter(|g| !g.is_empty()) {
|
||||
Some(gemfile) => Some(dir.join(gemfile)),
|
||||
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the lockfile of `gemfile` lists the `debug` gem.
|
||||
fn bundles_debug(gemfile: &Path) -> bool {
|
||||
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
|
||||
gemfile.with_file_name("gems.locked")
|
||||
} else {
|
||||
let mut name = gemfile.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
};
|
||||
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
|
||||
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
/// through `flutter debug_adapter` instead of the plain Dart one.
|
||||
///
|
||||
@@ -651,6 +707,9 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
@@ -794,14 +853,23 @@ impl DebugManager {
|
||||
let stderr = stderr.clone();
|
||||
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
|
||||
};
|
||||
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
|
||||
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
|
||||
.flatten();
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
// rdbg execs the debuggee in its own process and sends no DAP process event.
|
||||
if let Some(pid) = rdbg_pid {
|
||||
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
|
||||
}
|
||||
if let Some(mut output) = io.program_output {
|
||||
session.output_drained.send_replace(false);
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some((category, output)) = output.recv().await {
|
||||
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
|
||||
}
|
||||
session.output_drained.send_replace(true);
|
||||
});
|
||||
}
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
@@ -814,7 +882,12 @@ impl DebugManager {
|
||||
});
|
||||
tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming));
|
||||
|
||||
match self.configure(&session, arguments).await {
|
||||
let configured = tokio::select! {
|
||||
biased;
|
||||
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = self.configure(&session, arguments) => result,
|
||||
};
|
||||
match configured {
|
||||
Ok(()) => Ok(id),
|
||||
Err(e) => {
|
||||
let e = if session.conn.is_closed() {
|
||||
@@ -902,7 +975,9 @@ impl DebugManager {
|
||||
arguments["hostName"] = json!("127.0.0.1");
|
||||
arguments["port"] = json!(port);
|
||||
}
|
||||
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
|
||||
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
|
||||
// failure; Stop cancels configuration and a closed connection fails the pending reply.
|
||||
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
|
||||
let launch = s.conn.send_request(request, arguments)?;
|
||||
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
|
||||
let mut initialized = s.initialized.subscribe();
|
||||
@@ -914,7 +989,6 @@ impl DebugManager {
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
_ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
};
|
||||
if !got_initialized {
|
||||
// Launched before `initialized`; give the adapter a moment to send it.
|
||||
@@ -972,12 +1046,7 @@ impl DebugManager {
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
if launch_result.is_none() {
|
||||
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
|
||||
Ok(r) => {
|
||||
r?;
|
||||
}
|
||||
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
}
|
||||
launch.await?;
|
||||
}
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
@@ -1003,7 +1072,9 @@ impl DebugManager {
|
||||
// the console (`terminated`), as IDEA does with the process it owns.
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
let own_terminate = terminate && s.request == "launch" && pid > 0;
|
||||
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
|
||||
// rdbg's terminate exits the Ruby process without terminating its children.
|
||||
let graceful =
|
||||
terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest");
|
||||
if !s.conn.is_closed() {
|
||||
if graceful {
|
||||
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
|
||||
@@ -1659,9 +1730,11 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
|
||||
session.startup_cancel.cancel();
|
||||
let children: Vec<Arc<Session>> =
|
||||
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
|
||||
for child in children {
|
||||
child.startup_cancel.cancel();
|
||||
self.shutdown(&child, true).await;
|
||||
}
|
||||
self.shutdown(&session, true).await;
|
||||
|
||||
@@ -48,6 +48,8 @@ pub(crate) struct Session {
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
pub startup_cancel: ide_core::CancellationToken,
|
||||
pub output_drained: watch::Sender<bool>,
|
||||
pub terminated: AtomicBool,
|
||||
/// Set by a second Stop: the one waiting for the program kills it instead.
|
||||
pub stop_force: AtomicBool,
|
||||
@@ -103,6 +105,8 @@ impl Session {
|
||||
program: Mutex::new(None),
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
startup_cancel: ide_core::CancellationToken::new(),
|
||||
output_drained: watch::channel(true).0,
|
||||
terminated: AtomicBool::new(false),
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
@@ -275,6 +279,11 @@ impl Session {
|
||||
pub fn kill_processes(&self) {
|
||||
self.kotlin_relay.lock().take();
|
||||
if let Some(mut c) = self.adapter_process.lock().take() {
|
||||
if self.adapter == "rdbg"
|
||||
&& let Some(pid) = c.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
|
||||
@@ -448,6 +457,11 @@ pub(crate) async fn event_loop(
|
||||
Incoming::Closed => break,
|
||||
}
|
||||
}
|
||||
// rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary.
|
||||
if session.adapter == "rdbg" && session.request == "launch" {
|
||||
let mut drained = session.output_drained.subscribe();
|
||||
let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await;
|
||||
}
|
||||
if let Some(mgr) = manager.upgrade() {
|
||||
mgr.session_closed(&session);
|
||||
}
|
||||
@@ -588,15 +602,14 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
|
||||
/// A `runInTerminal` program and the pseudo-terminal it runs in.
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: Box<dyn std::io::Write + Send>,
|
||||
writer: ide_core::input::InputWriter,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
impl ProgramTerminal {
|
||||
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
|
||||
self.writer.write_all(text.as_bytes())?;
|
||||
self.writer.flush()
|
||||
self.writer.write(text.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +629,10 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
let pair = native_pty_system()
|
||||
.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 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"));
|
||||
@@ -641,10 +658,18 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
};
|
||||
}
|
||||
}
|
||||
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
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}");
|
||||
})
|
||||
.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);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
@@ -663,8 +688,19 @@ fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(0) => break,
|
||||
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 => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
continue;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
|
||||
let mut valid = match std::str::from_utf8(&pending) {
|
||||
|
||||
+268
-1
@@ -33,6 +33,12 @@ struct Script {
|
||||
supports_terminate: bool,
|
||||
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
|
||||
hold_set_breakpoints: usize,
|
||||
initialized_delay: Duration,
|
||||
launch_delay: Duration,
|
||||
/// Leave startup pending before `initialized` (false) or after configuration (true).
|
||||
pending_startup: Option<bool>,
|
||||
startup_error: Option<&'static str>,
|
||||
close_during_startup: bool,
|
||||
}
|
||||
|
||||
impl Default for Script {
|
||||
@@ -47,6 +53,11 @@ impl Default for Script {
|
||||
output_after_terminate: false,
|
||||
supports_terminate: true,
|
||||
hold_set_breakpoints: 0,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
pending_startup: None,
|
||||
startup_error: None,
|
||||
close_during_startup: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +131,15 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
})
|
||||
),
|
||||
"launch" | "attach" => {
|
||||
if script.close_during_startup {
|
||||
return;
|
||||
}
|
||||
if let Some(message) = script.startup_error {
|
||||
send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": msg["command"], "success": false, "message": message})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pending_launch = msg["seq"].as_i64();
|
||||
if let Some(args) = &script.run_in_terminal {
|
||||
send!(
|
||||
@@ -131,7 +151,10 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
|
||||
);
|
||||
}
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
if script.pending_startup != Some(false) {
|
||||
tokio::time::sleep(script.initialized_delay).await;
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
}
|
||||
"setBreakpoints" => {
|
||||
held.push(msg);
|
||||
@@ -182,6 +205,10 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
if !script.ignore_configuration_done {
|
||||
ok!(msg, Value::Null);
|
||||
}
|
||||
if script.pending_startup == Some(true) {
|
||||
continue;
|
||||
}
|
||||
tokio::time::sleep(script.launch_delay).await;
|
||||
if let Some(l) = pending_launch.take() {
|
||||
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
|
||||
}
|
||||
@@ -351,6 +378,66 @@ fn bp(line: u32) -> Breakpoint {
|
||||
Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true }
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn slow_launch_and_attach_follow_adapter_startup() {
|
||||
for request in ["launch", "attach"] {
|
||||
for before_initialized in [true, false] {
|
||||
let delay = Duration::from_secs(125);
|
||||
let h = harness(Script {
|
||||
initialized_delay: if before_initialized { delay } else { Duration::ZERO },
|
||||
launch_delay: if before_initialized { Duration::ZERO } else { delay },
|
||||
..Script::default()
|
||||
});
|
||||
let config = LaunchConfig { request: request.into(), ..config() };
|
||||
let id = h.start(config).await.expect("a slow adapter can finish starting");
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert!(!h.commands().iter().any(|c| c == "terminate" || c == "disconnect"));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn stop_cancels_pending_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
for configured in [false, true] {
|
||||
let h = harness(Script { pending_startup: Some(configured), ..Script::default() });
|
||||
let config = LaunchConfig { request: request.into(), ..config() };
|
||||
let start = h.start(config);
|
||||
tokio::pin!(start);
|
||||
let stop = async {
|
||||
loop {
|
||||
let command = if configured { "configurationDone" } else { request };
|
||||
if h.commands().iter().any(|c| c == command) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
let id = h.manager.sessions(None)[0].id;
|
||||
h.manager.stop(id).await.unwrap();
|
||||
};
|
||||
let (result, ()) = tokio::join!(&mut start, stop);
|
||||
assert_eq!(result.unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn startup_still_reports_adapter_failure_and_connection_closure() {
|
||||
for request in ["launch", "attach"] {
|
||||
let h = harness(Script { startup_error: Some("interpreter could not start"), ..Script::default() });
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
|
||||
let h = harness(Script { close_during_startup: true, ..Script::default() });
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_protocol_flow() {
|
||||
let h = harness(Script::default());
|
||||
@@ -554,6 +641,22 @@ async fn run_in_terminal_program_reads_typed_input() {
|
||||
assert!(h.manager.send_input(session, "x\n").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn blocked_program_input_does_not_hold_up_shutdown() {
|
||||
let h = harness(Script {
|
||||
run_in_terminal: Some(vec!["sh".into(), "-c".into(), "stty raw -echo; printf READY; exec sleep 60".into()]),
|
||||
..Script::default()
|
||||
});
|
||||
let session = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("READY"))).await;
|
||||
let started = std::time::Instant::now();
|
||||
h.manager.send_input(session, &"x".repeat(512 * 1024)).unwrap();
|
||||
assert!(started.elapsed() < std::time::Duration::from_millis(250));
|
||||
h.manager.stop(session).await.unwrap();
|
||||
assert!(h.manager.send_input(session, "later").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
@@ -1391,6 +1494,109 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig {
|
||||
adapter: "rdbg".into(),
|
||||
program: Some("ruby".into()),
|
||||
args: ["-Ilib", "app with spaces.rb", "$(touch sentinel); literal"].map(String::from).to_vec(),
|
||||
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
|
||||
extra_json: None,
|
||||
..config()
|
||||
};
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(spec.command, "rdbg");
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app with spaces.rb", "$(touch sentinel); literal"]
|
||||
);
|
||||
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = dir.path().join("app");
|
||||
std::fs::create_dir_all(app.join("lib")).unwrap();
|
||||
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
|
||||
// A dependency of another gem is not a gem of the bundle.
|
||||
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig {
|
||||
adapter: "rdbg".into(),
|
||||
program: Some("bundle".into()),
|
||||
args: ["exec", "rspec"].map(String::from).to_vec(),
|
||||
extra_json: None,
|
||||
..config()
|
||||
};
|
||||
let mut spec = adapter::resolve(&rdbg, app.clone());
|
||||
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
|
||||
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
|
||||
|
||||
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
|
||||
// From a subdirectory too: Bundler finds the Gemfile above it.
|
||||
for cwd in [app.clone(), app.join("lib")] {
|
||||
let mut spec = adapter::resolve(&rdbg, cwd.clone());
|
||||
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
|
||||
assert_eq!(spec.command, "bundle");
|
||||
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_commands_without_arguments_are_ruby_entry_points_not_shell_text() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
for program in ["rspec", "/app with spaces/bin/rake", "$(touch sentinel); literal"] {
|
||||
let config =
|
||||
LaunchConfig { adapter: "rdbg".into(), program: Some(program.into()), args: Vec::new(), ..config() };
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(&spec.args[4..], ["--", "ruby", "-S", "--", program]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_remote_attach_does_not_check_the_local_bundle_or_append_a_program() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Gemfile"), "").unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig { adapter: "rdbg".into(), request: "attach".into(), ..config() };
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
let args = spec.args.clone();
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(spec.command, "rdbg");
|
||||
assert_eq!(spec.args, args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundler_prefers_gems_rb_when_both_gemfiles_exist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["Gemfile", "gems.rb"] {
|
||||
std::fs::write(dir.path().join(name), "").unwrap();
|
||||
}
|
||||
assert_eq!(bundler_gemfile(dir.path(), &BTreeMap::new()), Some(dir.path().join("gems.rb")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("gems")).unwrap();
|
||||
let none = BTreeMap::new();
|
||||
assert_eq!(bundler_gemfile(root, &none), None);
|
||||
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
|
||||
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
|
||||
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
|
||||
assert_eq!(gems_rb, root.join("gems/gems.rb"));
|
||||
assert!(bundles_debug(&gems_rb));
|
||||
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
|
||||
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_waits_for_the_launched_program_to_exit() {
|
||||
@@ -1608,3 +1814,64 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
struct Launcher;
|
||||
impl AdapterLauncher for Launcher {
|
||||
fn launch(&self, _spec: AdapterSpec, _reuse: Option<u16>) -> LaunchFuture {
|
||||
Box::pin(async {
|
||||
let (client, server) = tokio::io::duplex(1 << 16);
|
||||
tokio::spawn(fake_adapter(server, Arc::default(), Script::default()));
|
||||
let mut command = tokio::process::Command::new("/bin/sh");
|
||||
ide_core::process::own_group(command.as_std_mut());
|
||||
let mut child = command
|
||||
.args(["-c", "sleep 1; printf 'test summary\\n'"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
let mut text = String::new();
|
||||
tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut text).await.unwrap();
|
||||
let _ = tx.send(("stdout", text));
|
||||
});
|
||||
let (reader, writer) = tokio::io::split(client);
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(reader),
|
||||
writer: Box::new(writer),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr: Arc::default(),
|
||||
program_output: Some(rx),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(Launcher));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let collected = events.clone();
|
||||
let owner = SessionOwner::new("rdbg-test", Arc::new(move |e| collected.lock().push(e)));
|
||||
let id = manager
|
||||
.start(
|
||||
LaunchConfig { adapter: "rdbg".into(), cwd: Some(dir.path().into()), extra_json: None, ..config() },
|
||||
owner,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
manager.session(id).unwrap().conn.close();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !manager.sessions(None).is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(events.lock().iter().any(|e| matches!(e,
|
||||
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
|
||||
)));
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -134,21 +134,22 @@ pub async fn inspect_code(
|
||||
let cancel = host.begin_request(request);
|
||||
let listed = {
|
||||
let (project, cancel) = (project.clone(), cancel.clone());
|
||||
blocking(move || files_in_scope(&project, &scope, &cancel)).await
|
||||
blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let total = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> =
|
||||
files.into_iter().filter(|f| project.lsp.serves_path(&f.path)).map(|f| (f.path, f.rel_path)).collect();
|
||||
Ok((total, rel))
|
||||
})
|
||||
.await
|
||||
};
|
||||
let files = match listed {
|
||||
let (total_in_scope, rel) = match listed {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
host.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let total_in_scope = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> = files
|
||||
.iter()
|
||||
.filter(|f| project.lsp.serves_path(&f.path))
|
||||
.map(|f| (f.path.clone(), f.rel_path.clone()))
|
||||
.collect();
|
||||
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
|
||||
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Maven / Gradle tool windows (the build model and its background sync) and
|
||||
//! Build › Build Project (compiling with the project's build system).
|
||||
mod workspace;
|
||||
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use crate::{Host, Project, apply_workspace_edit};
|
||||
@@ -10,7 +12,6 @@ use ide_build::pom_edit::{ArtifactKey, Exclusion};
|
||||
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
@@ -125,8 +126,8 @@ fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || workspace::load_project(&project, &config)).await.map_err(join_err)?
|
||||
}
|
||||
|
||||
fn linked_gradle_paths(
|
||||
@@ -224,84 +225,89 @@ pub async fn build_sync(
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let bridge_profiles = profiles.clone();
|
||||
// What the Java language server and Build Project resolve the project with from now on.
|
||||
let profiles_changed = tool == BuildTool::Maven
|
||||
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
if profiles_changed {
|
||||
// The modules of the chosen profiles are read from the POMs with them from now on (the
|
||||
// project tree's module folders too), whether or not Maven itself can run.
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
let token = host.begin_project_request(window, request);
|
||||
let completion_token = token.clone();
|
||||
let original_links = settings.languages.java.gradle.linked_projects.clone();
|
||||
let bridge_profiles = profiles.clone();
|
||||
let p = project.clone();
|
||||
let config = host.config_dir.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
BuildTool::Maven => current
|
||||
let scopes = workspace::scopes(&p, &config)?;
|
||||
let selected: Vec<_> =
|
||||
scopes.iter().filter(|scope| workspace::selected(scope, &p.root, tool, build_dir.as_deref())).collect();
|
||||
if selected.is_empty() {
|
||||
return Err(IdeError::not_found("Build project is no longer linked"));
|
||||
}
|
||||
let mut profiles_changed = false;
|
||||
for scope in selected {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let (root, cache, settings) = (&scope.root, &scope.cache, &scope.settings);
|
||||
let current = load(root, cache, settings);
|
||||
if tool == BuildTool::Maven && current.maven.is_some() {
|
||||
let changed = ide_build::save_maven_profiles(cache, &profiles).unwrap_or_else(|e| {
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
if changed {
|
||||
p.sink.emit(IdeEvent::ProjectRootsChanged { root: p.root.clone().into() });
|
||||
}
|
||||
profiles_changed |= changed;
|
||||
}
|
||||
let env = current
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => BTreeMap::new(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles,
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => {
|
||||
if let Some(dir) = &build_dir
|
||||
&& !current.gradle.iter().any(|g| &g.dir == dir)
|
||||
{
|
||||
return Err(IdeError::not_found("Gradle project is no longer linked"));
|
||||
.unwrap_or_default();
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles: profiles.clone(),
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(root, settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(root, settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven if current.maven.is_some() => {
|
||||
ide_build::sync_maven(root, cache, &options, &token, &progress)?
|
||||
}
|
||||
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
BuildTool::Maven => {}
|
||||
BuildTool::Gradle => {
|
||||
for g in current
|
||||
.gradle
|
||||
.iter()
|
||||
.filter(|g| build_dir.as_ref().is_none_or(|dir| p.root.join(dir) == root.join(&g.dir)))
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options = SyncOptions {
|
||||
env: g.env.clone(),
|
||||
gradle_command: Some(g.command.clone()),
|
||||
..options.clone()
|
||||
};
|
||||
ide_build::sync_gradle_model(root, cache, g, &options, &token, &progress)?;
|
||||
}
|
||||
let options =
|
||||
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
|
||||
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
|
||||
}
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
let scopes = workspace::scopes(&p, &config)?;
|
||||
let model = workspace::load_scopes(&p.root, &scopes);
|
||||
let updates = workspace::update_files(&p.root, &scopes, &model, tool);
|
||||
Ok::<_, IdeError>((model, profiles_changed, updates))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let model = result??;
|
||||
let (model, profiles_changed, updates) = result??;
|
||||
if completion_token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
|
||||
{
|
||||
// A late sync must not re-import a build that was unlinked while it ran.
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
}
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
@@ -320,30 +326,17 @@ pub async fn build_sync(
|
||||
// to update the project configurations re-resolves them (#436). The same
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let build_files: Vec<String> = match tool {
|
||||
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
|
||||
BuildTool::Gradle => {
|
||||
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
|
||||
}
|
||||
};
|
||||
let lsp = project.lsp.clone();
|
||||
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
|
||||
// not the reactor: naming the reactor here would import it again, all modules included.
|
||||
let imported = match tool {
|
||||
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
|
||||
BuildTool::Gradle => None,
|
||||
};
|
||||
let uris = match imported {
|
||||
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
|
||||
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
for file in updates {
|
||||
let uri = ide_lsp::path_to_uri_string(&file);
|
||||
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(model)
|
||||
@@ -353,12 +346,25 @@ pub async fn build_sync(
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
project: &Project,
|
||||
config: &Path,
|
||||
pom: &str,
|
||||
dependency: &ArtifactKey,
|
||||
) -> IdeResult<(std::path::PathBuf, String, Range)> {
|
||||
let model = ide_build::maven::load(&project.root);
|
||||
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
|
||||
let path = project.root.join(&rel);
|
||||
let scopes = workspace::scopes(project, config)?;
|
||||
let path = project.root.join(pom);
|
||||
let scope = scopes
|
||||
.iter()
|
||||
.filter(|s| path.starts_with(&s.root))
|
||||
.max_by_key(|s| s.root.components().count())
|
||||
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings).maven.unwrap_or_default();
|
||||
let pom = model
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| scope.root.join(&p.pom) == path)
|
||||
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
|
||||
for rel in ide_build::pom_edit::declaring_poms(&model, &pom.pom) {
|
||||
let path = scope.root.join(&rel);
|
||||
let text = project.text_of(&path)?;
|
||||
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
|
||||
return Ok((path, text, range));
|
||||
@@ -398,14 +404,19 @@ pub async fn gradle_dependencies(
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let config = host.config_dir.clone();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let model = load(&root, &cache, &settings);
|
||||
let scopes = workspace::scopes(&project, &config)?;
|
||||
let scope = scopes
|
||||
.iter()
|
||||
.find(|s| workspace::selected(s, &project.root, BuildTool::Gradle, build_dir.as_deref()))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
let gradle = model
|
||||
.gradle
|
||||
.iter()
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| project.root.join(dir) == scope.root.join(&g.dir)))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
@@ -413,7 +424,7 @@ pub async fn gradle_dependencies(
|
||||
gradle_command: Some(gradle.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
ide_build::gradle_dependencies(&scope.root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -429,8 +440,9 @@ pub async fn maven_dependency_declaration(
|
||||
dependency: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
let (path, _, range) = declaring_pom(&project, &config, &pom, &dependency)?;
|
||||
Ok(Location::file(path, range))
|
||||
})
|
||||
.await
|
||||
@@ -469,8 +481,9 @@ pub async fn maven_exclude_dependency(
|
||||
exclusion: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
let (path, text, range) = declaring_pom(&project, &config, &pom, &dependency)?;
|
||||
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
|
||||
Some(Exclusion::Edit(edit)) => {
|
||||
let edit = WorkspaceEdit {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) struct Scope {
|
||||
pub root: PathBuf,
|
||||
pub cache: PathBuf,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>> {
|
||||
scopes_at(&project.root, &project.build_cache_dir(), config, project.settings.effective())
|
||||
}
|
||||
|
||||
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
|
||||
Ok(match ide_workspace::workspace::Workspace::load(root)? {
|
||||
Some(workspace) => workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.map(|root| Scope {
|
||||
cache: cache.join(crate::project::project_key(&root)),
|
||||
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
|
||||
root,
|
||||
})
|
||||
.collect(),
|
||||
None => vec![Scope { root: root.to_owned(), cache: cache.to_owned(), settings }],
|
||||
})
|
||||
}
|
||||
|
||||
fn absolute(root: &Path, path: &str) -> String {
|
||||
let path = if path.is_empty() { root.to_owned() } else { root.join(path) };
|
||||
ide_core::ipc_path::to_ipc(&path)
|
||||
}
|
||||
|
||||
pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
|
||||
let mut combined = BuildModel::default();
|
||||
for scope in scopes {
|
||||
let mut model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
if scope.root == root {
|
||||
return model;
|
||||
}
|
||||
if let Some(maven) = &mut model.maven {
|
||||
let owner = ide_core::ipc_path::to_ipc(&scope.root);
|
||||
for p in &mut maven.projects {
|
||||
p.project_root = Some(owner.clone());
|
||||
p.pom = absolute(&scope.root, &p.pom);
|
||||
p.dir = absolute(&scope.root, &p.dir);
|
||||
for module in &mut p.modules {
|
||||
*module = absolute(&scope.root, module);
|
||||
}
|
||||
}
|
||||
for profile in &mut maven.profiles {
|
||||
profile.project_root = Some(owner.clone());
|
||||
}
|
||||
}
|
||||
if let Some(maven) = model.maven {
|
||||
match &mut combined.maven {
|
||||
None => combined.maven = Some(maven),
|
||||
Some(all) => {
|
||||
all.projects.extend(maven.projects);
|
||||
all.profiles.extend(maven.profiles);
|
||||
all.synced_at = match (all.synced_at, maven.synced_at) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
_ => None,
|
||||
};
|
||||
all.stale |= maven.stale;
|
||||
if let Some(error) = maven.sync_error {
|
||||
all.sync_error = Some(match all.sync_error.take() {
|
||||
Some(previous) => format!("{previous}\n{error}"),
|
||||
None => error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for g in &mut model.gradle {
|
||||
g.dir = absolute(&scope.root, &g.dir);
|
||||
for p in &mut g.projects {
|
||||
p.dir = absolute(&scope.root, &p.dir);
|
||||
p.build_file = p.build_file.as_ref().map(|f| absolute(&scope.root, f));
|
||||
}
|
||||
}
|
||||
combined.gradle.extend(model.gradle);
|
||||
}
|
||||
combined
|
||||
}
|
||||
|
||||
pub(super) fn load_project(project: &Project, config: &Path) -> IdeResult<BuildModel> {
|
||||
Ok(load_scopes(&project.root, &scopes(project, config)?))
|
||||
}
|
||||
|
||||
pub(super) fn selected(scope: &Scope, window_root: &Path, tool: BuildTool, dir: Option<&str>) -> bool {
|
||||
let Some(dir) = dir else { return true };
|
||||
match tool {
|
||||
BuildTool::Maven => scope.root == Path::new(dir),
|
||||
BuildTool::Gradle => load(&scope.root, &scope.cache, &scope.settings)
|
||||
.gradle
|
||||
.iter()
|
||||
.any(|g| window_root.join(dir) == scope.root.join(&g.dir)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build files select their member's language server, including independent nested builds.
|
||||
pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, tool: BuildTool) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
match tool {
|
||||
BuildTool::Maven => {
|
||||
for scope in scopes {
|
||||
// A reactor with unlinked modules must not be imported as a whole again.
|
||||
let imported = crate::project::jdtls_maven_import(&scope.root, &scope.settings).1;
|
||||
let paths: Vec<_> = match imported {
|
||||
Some(uris) => uris
|
||||
.iter()
|
||||
.filter_map(|u| ide_lsp::uri_to_path(u))
|
||||
.filter(|p| p.file_name().is_some_and(|n| n == "pom.xml"))
|
||||
.collect(),
|
||||
None => model
|
||||
.maven
|
||||
.iter()
|
||||
.flat_map(|m| &m.projects)
|
||||
.filter(|p| p.project_root.as_ref().is_none_or(|owner| Path::new(owner) == scope.root))
|
||||
.map(|p| root.join(&p.pom))
|
||||
.collect(),
|
||||
};
|
||||
for path in paths {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BuildTool::Gradle => {
|
||||
for file in model.gradle.iter().flat_map(|g| &g.projects).filter_map(|p| p.build_file.as_ref()) {
|
||||
let path = root.join(file);
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_models_use_member_settings_paths_and_caches() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let workspace = base.join("workspace");
|
||||
let config = base.join("config");
|
||||
let cache = base.join("cache");
|
||||
let members: Vec<_> = ["first", "second"].into_iter().map(|name| base.join(name)).collect();
|
||||
for member in &members {
|
||||
std::fs::create_dir_all(member.join("extra")).unwrap();
|
||||
std::fs::write(
|
||||
member.join("pom.xml"),
|
||||
r#"<project><artifactId>app</artifactId>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles>
|
||||
</project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(member.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(member.join("settings.gradle"), "rootProject.name = 'app'").unwrap();
|
||||
std::fs::write(member.join("build.gradle"), "").unwrap();
|
||||
}
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", members.clone(), true).unwrap();
|
||||
let first_cache = cache.join(crate::project::project_key(&members[0]));
|
||||
ide_build::save_maven_profiles(&first_cache, &["extra".into()]).unwrap();
|
||||
let settings = ide_workspace::SettingsStore::load(&config, Some(&members[1]));
|
||||
settings
|
||||
.set(
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"languages.java.gradle.linkedProjects",
|
||||
serde_json::json!([]),
|
||||
)
|
||||
.unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(scopes[0].cache, first_cache);
|
||||
let model = load_scopes(&workspace, &scopes);
|
||||
let maven = model.maven.as_ref().unwrap();
|
||||
assert_eq!(maven.projects.len(), 3);
|
||||
assert_eq!(maven.projects[0].modules, [absolute(&members[0], "extra/pom.xml")]);
|
||||
assert!(maven.projects.iter().all(|p| Path::new(&p.pom).is_absolute() && Path::new(&p.dir).is_absolute()));
|
||||
assert_eq!(maven.profiles.len(), 2);
|
||||
assert_ne!(maven.profiles[0].project_root, maven.profiles[1].project_root);
|
||||
let updates = update_files(&workspace, &scopes, &model, BuildTool::Maven);
|
||||
assert_eq!(updates.len(), 3);
|
||||
assert!(updates.iter().all(|p| members.iter().any(|root| p.starts_with(root))));
|
||||
assert_eq!(update_files(&workspace, &scopes, &model, BuildTool::Gradle), [members[0].join("build.gradle")]);
|
||||
assert_eq!(model.gradle.len(), 1);
|
||||
assert_eq!(model.gradle[0].dir, absolute(&members[0], ""));
|
||||
assert!(selected(&scopes[0], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
|
||||
assert!(!selected(&scopes[1], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
|
||||
assert!(selected(&scopes[0], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
|
||||
assert!(!selected(&scopes[1], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
|
||||
assert_eq!(ide_build::maven_profiles(&scopes[1].cache), Vec::<String>::new());
|
||||
let standalone = scopes_at(&members[0], &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(load_scopes(&members[0], &standalone), load(&members[0], &cache, &Settings::default()));
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", vec![members[1].clone()], false).unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
let model = load_scopes(&workspace, &scopes);
|
||||
assert_eq!(model.maven.unwrap().projects.len(), 1);
|
||||
assert!(model.gradle.is_empty());
|
||||
std::fs::remove_dir_all(&members[1]).unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(load_scopes(&workspace, &scopes), BuildModel::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keeps_unlinked_modules_out_of_member_language_servers() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(temp.path()).unwrap();
|
||||
for name in ["enabled", "hidden"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(name).join("pom.xml"),
|
||||
format!("<project><artifactId>{name}</artifactId></project>"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(root.join("pom.xml"), "<project><artifactId>parent</artifactId><packaging>pom</packaging><modules><module>enabled</module><module>hidden</module></modules></project>").unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.maven.unlinked_projects = vec!["hidden/pom.xml".into()];
|
||||
let scope = Scope { root: root.clone(), cache: root.join("cache"), settings };
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
assert_eq!(update_files(&root, &[scope], &model, BuildTool::Maven), [root.join("enabled/pom.xml")]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -75,8 +75,20 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
config.extra_json = Some(extra.to_string());
|
||||
}
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let mut ruby_tools = Vec::new();
|
||||
if config.adapter == "rdbg" && config.request == "launch" {
|
||||
let project_root = host.project(window).map(|p| p.root.clone()).unwrap_or_else(|_| root.clone());
|
||||
let (dir, settings) = (root.clone(), settings.clone());
|
||||
(config, ruby_tools) = host
|
||||
.blocking(move || {
|
||||
let tools = ide_sdk::ruby::debug_environment(&project_root, &dir, &settings, &mut config.env);
|
||||
Ok((config, tools))
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)
|
||||
.or_else(|| ruby_tools.into_iter().find(|(name, _)| *name == command).map(|(_, path)| path))?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
|
||||
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
|
||||
use ide_run::BuildPolicy;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cancellable with `cancel_request(request)`.
|
||||
@@ -19,12 +20,12 @@ pub async fn java_resolve_launch(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
policy: BuildPolicy,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = resolve_launch(host, window, module, test, build, &cancel).await;
|
||||
let result = resolve_launch(host, window, module, test, policy, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
@@ -34,13 +35,17 @@ async fn resolve_launch(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
policy: BuildPolicy,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let started = std::time::Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
// A launch builds incrementally unless the configuration asks for Rebuild or NoBuild.
|
||||
let build = policy != BuildPolicy::NoBuild;
|
||||
let full = policy == BuildPolicy::Rebuild;
|
||||
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let (model, external, delegated) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
@@ -59,7 +64,7 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?;
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
@@ -70,17 +75,23 @@ async fn resolve_launch(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
test,
|
||||
cache::BuildOptions { test, policy },
|
||||
ide_sdk::apply::maven_local_repository(&project.root, &settings),
|
||||
&cancel,
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
info.build = Some(JavaBuildStatus::Succeeded);
|
||||
info.errors.clear();
|
||||
info.error_count = 0;
|
||||
apply_external_build_result(&mut info, outputs.failure);
|
||||
copies
|
||||
} else if external {
|
||||
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
|
||||
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
|
||||
if let Some(failure) = built.failure {
|
||||
if policy != BuildPolicy::MakeAndContinueOnErrors {
|
||||
return Err(IdeError::internal(failure));
|
||||
}
|
||||
info.build = Some(JavaBuildStatus::Failed);
|
||||
info.hints.push(failure);
|
||||
}
|
||||
built.class_dirs
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -138,6 +149,18 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>) {
|
||||
info.build = Some(if let Some(failure) = failure {
|
||||
info.hints.push(failure);
|
||||
JavaBuildStatus::Failed
|
||||
} else if info.error_count > 0 {
|
||||
// Incremental Maven compilation can accept error-stub classes written by JDT.
|
||||
JavaBuildStatus::WithErrors
|
||||
} else {
|
||||
JavaBuildStatus::Succeeded
|
||||
});
|
||||
}
|
||||
|
||||
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
|
||||
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
|
||||
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
|
||||
@@ -235,27 +258,63 @@ pub async fn java_build_workspace(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
// HotSwap may cancel the compile (the request id is allocated by the caller).
|
||||
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
|
||||
let result = project.lsp.java_build(&module, false, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_resolve_launch", |c| async move {
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("policy")?, c.arg("request")?)
|
||||
.await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move {
|
||||
java_build_workspace(&c.host, &c.window, c.arg("module")?, c.arg("request")?).await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn successful_external_compilation_does_not_erase_scoped_jdt_errors() {
|
||||
for (failure, expected) in
|
||||
[(None, JavaBuildStatus::WithErrors), (Some("compile failed"), JavaBuildStatus::Failed)]
|
||||
{
|
||||
let mut info = JavaLaunchInfo {
|
||||
error_count: 1,
|
||||
errors: vec![ide_lsp::jdtls::JavaCompileError {
|
||||
path: "src/main/java/Main.java".into(),
|
||||
line: 4,
|
||||
column: 28,
|
||||
message: "missingName cannot be resolved to a variable".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let errors = info.errors.clone();
|
||||
apply_external_build_result(&mut info, failure.map(str::to_owned));
|
||||
assert_eq!(info.build, Some(expected));
|
||||
assert_eq!(info.error_count, 1);
|
||||
assert_eq!(info.errors, errors);
|
||||
assert_eq!(info.hints, failure.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
let mut info = JavaLaunchInfo::default();
|
||||
apply_external_build_result(&mut info, None);
|
||||
assert_eq!(info.build, Some(JavaBuildStatus::Succeeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
|
||||
use ide_core::settings::{DependencyScope, ProjectLibrary};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
|
||||
use ide_build::compile::{self, LaunchFingerprint};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_run::BuildPolicy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -11,6 +12,8 @@ pub(super) struct Outputs {
|
||||
fingerprint: Option<LaunchFingerprint>,
|
||||
pub snapshot: PathBuf,
|
||||
pub originals: Vec<PathBuf>,
|
||||
#[serde(skip)]
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
@@ -35,6 +38,12 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
|
||||
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BuildOptions {
|
||||
pub test: bool,
|
||||
pub policy: BuildPolicy,
|
||||
}
|
||||
|
||||
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
|
||||
pub(crate) struct Session {
|
||||
cache: PathBuf,
|
||||
@@ -61,11 +70,12 @@ impl Session {
|
||||
root: &Path,
|
||||
model: &ide_build::BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
options: BuildOptions,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outputs> {
|
||||
self.initialize();
|
||||
let BuildOptions { test, policy } = options;
|
||||
let cache = &self.cache;
|
||||
let record =
|
||||
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
|
||||
@@ -81,6 +91,7 @@ impl Session {
|
||||
let before = fingerprint()?;
|
||||
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
|
||||
if let Some(outputs) = previous.as_ref()
|
||||
&& policy != BuildPolicy::Rebuild
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
{
|
||||
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
|
||||
@@ -90,10 +101,27 @@ impl Session {
|
||||
// source still delegates to Maven to clean its old classes on the next launch.
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
fs::write(&record, b"null")?;
|
||||
if let Some(previous) = previous {
|
||||
self.unreferenced.push(previous.snapshot);
|
||||
if let Some(previous) = &previous {
|
||||
self.unreferenced.push(previous.snapshot.clone());
|
||||
}
|
||||
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
|
||||
let kind = if policy == BuildPolicy::Rebuild { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let built = compile::build_for_launch(root, model, module, test, kind, cancel)?;
|
||||
if let Some(failure) = &built.failure {
|
||||
if policy != BuildPolicy::MakeAndContinueOnErrors {
|
||||
return Err(IdeError::internal(failure.clone()));
|
||||
}
|
||||
if let Some(mut previous) =
|
||||
previous.filter(|p| (0..p.originals.len()).all(|i| p.snapshot.join(i.to_string()).is_dir()))
|
||||
{
|
||||
previous.fingerprint = None;
|
||||
previous.failure = built.failure;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
serde_json::to_writer(file.as_file_mut(), &previous).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
|
||||
return Ok(previous);
|
||||
}
|
||||
}
|
||||
let originals = built.class_dirs;
|
||||
fs::create_dir_all(&snapshots)?;
|
||||
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
|
||||
// Retain every new snapshot even if copying or publishing its record fails.
|
||||
@@ -108,8 +136,8 @@ impl Session {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
|
||||
if before.is_some() && before == after {
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
|
||||
if outputs.failure.is_none() && before.is_some() && before == after {
|
||||
outputs.fingerprint = before;
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
@@ -199,13 +227,77 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let script = root.join("mvnw");
|
||||
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
|
||||
ide_build::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(root.join("cache"));
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let rebuilt = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::Rebuild, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(first.snapshot, rebuilt.snapshot);
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 2);
|
||||
assert!(fs::read_to_string(root.join("calls")).unwrap().lines().last().unwrap().contains("clean"));
|
||||
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
|
||||
let continued = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(continued.snapshot, rebuilt.snapshot);
|
||||
assert!(continued.failure.unwrap().contains("Build failed"));
|
||||
assert_eq!(fs::read_to_string(continued.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
let again = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(again.snapshot, rebuilt.snapshot);
|
||||
assert!(again.failure.is_some());
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -214,7 +306,7 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let mut build = || session.build(root, &model, root, false, None, &cancel);
|
||||
let mut build = || session.build(root, &model, root, BuildOptions::default(), None, &cancel);
|
||||
let first = build().unwrap();
|
||||
let snapshot = first.snapshot.clone();
|
||||
drop(first);
|
||||
@@ -253,14 +345,14 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
|
||||
let source = root.join("src/main/kotlin/Main.kt");
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
|
||||
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
|
||||
}
|
||||
@@ -273,9 +365,9 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let second = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
// Another mode can keep a superseded snapshot alive.
|
||||
@@ -286,10 +378,10 @@ mod tests {
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
let mut session = Session::new(cache.clone());
|
||||
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let reused = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_eq!(reused.snapshot, second.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
|
||||
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let third = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert!(second.snapshot.is_dir());
|
||||
drop(session);
|
||||
assert!(!second.snapshot.exists());
|
||||
@@ -316,7 +408,7 @@ mod tests {
|
||||
fs::write(snapshots.join("launch-file"), "keep").unwrap();
|
||||
let record = cache.join("java-launch/records/deep/module/test.json");
|
||||
fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
|
||||
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
|
||||
let mut session = Session::new(cache);
|
||||
session.initialize();
|
||||
@@ -347,7 +439,7 @@ mod tests {
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
|
||||
assert!(session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).is_err());
|
||||
assert_eq!(session.unreferenced.len(), 1);
|
||||
let snapshot = session.unreferenced[0].clone();
|
||||
assert!(snapshot.join("0/Main.class").is_file());
|
||||
@@ -366,7 +458,8 @@ mod tests {
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
|
||||
let outputs =
|
||||
session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).unwrap();
|
||||
assert!(outputs.fingerprint.is_none());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
assert!(outputs.snapshot.join("0/Main.class").is_file());
|
||||
@@ -381,14 +474,14 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
|
||||
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
|
||||
assert!(has_record(root, &cache, root));
|
||||
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
|
||||
assert!(session.build(root, &model, root, BuildOptions::default(), None, &cancel).is_err());
|
||||
assert!(has_record(root, &cache, root));
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
|
||||
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let restored = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_ne!(first.snapshot, restored.snapshot);
|
||||
}
|
||||
}
|
||||
+406
-15
@@ -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;
|
||||
}
|
||||
@@ -271,6 +340,26 @@ fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapsh
|
||||
stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind)))
|
||||
}
|
||||
|
||||
fn identifier_at(
|
||||
structure: &ide_syntax::StructureService,
|
||||
snap: &ide_syntax::Snapshot,
|
||||
range: Range,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> bool {
|
||||
let Ok(Some(at)) = structure.node_at(snap, range, cancel) else { return false };
|
||||
at.node.kind.contains("identifier")
|
||||
|| matches!(
|
||||
at.node.kind.as_str(),
|
||||
"name"
|
||||
| "constant"
|
||||
| "label_name"
|
||||
| "variable_name"
|
||||
| "instance_variable"
|
||||
| "class_variable"
|
||||
| "global_variable"
|
||||
)
|
||||
}
|
||||
|
||||
fn definition_targets(
|
||||
server: IdeResult<Vec<Location>>,
|
||||
plugin: Vec<Location>,
|
||||
@@ -338,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
|
||||
}
|
||||
|
||||
@@ -360,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
|
||||
}
|
||||
|
||||
@@ -485,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());
|
||||
@@ -497,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());
|
||||
@@ -535,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(
|
||||
@@ -577,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
|
||||
};
|
||||
@@ -788,6 +901,12 @@ async fn is_client_follow_up(
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? {
|
||||
let project = host.project(window)?;
|
||||
if let Some(refusal) = indexed_code_identifier(host, window, doc, symbol.range, None).await?
|
||||
&& let Err(e) = project.lsp.prepare_rename(doc, pos).await
|
||||
&& indexed_lsp_error(e)?.is_none()
|
||||
{
|
||||
return Err(refusal);
|
||||
}
|
||||
return blocking(move || {
|
||||
validate_indexed_symbol(&project, &symbol, None)?;
|
||||
Ok(Some(symbol.range))
|
||||
@@ -883,7 +1002,15 @@ pub async fn lsp_rename(
|
||||
let mut renamed = match result {
|
||||
Ok(renamed) => renamed,
|
||||
Err(e) if symbol.is_some() && !native_owned => {
|
||||
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
|
||||
let incomplete = indexed_lsp_error(e)?;
|
||||
if incomplete.is_none()
|
||||
&& let Some(symbol) = &symbol
|
||||
&& let Some(refusal) =
|
||||
indexed_code_identifier(host, window, doc, symbol.range, Some(cancel.clone())).await?
|
||||
{
|
||||
return Err(refusal);
|
||||
}
|
||||
Renamed { incomplete, ..Default::default() }
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
@@ -921,6 +1048,39 @@ pub async fn lsp_rename(
|
||||
result
|
||||
}
|
||||
|
||||
async fn indexed_code_identifier(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
cancel: Option<ide_core::CancellationToken>,
|
||||
) -> IdeResult<Option<ide_core::IdeError>> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = cancel.unwrap_or_default();
|
||||
blocking(move || {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(None);
|
||||
}
|
||||
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
|
||||
let identifier = identifier_at(&project.structure, &s, range, &cancel);
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
if !identifier {
|
||||
return Ok(None);
|
||||
}
|
||||
let language = project.languages.get(&snap.language).map_or_else(|| snap.language.to_string(), |l| l.name);
|
||||
Ok(Some(ide_core::IdeError::invalid(format!(
|
||||
"The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed"
|
||||
))))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn merge_indexed_rename(
|
||||
renamed: &mut Renamed,
|
||||
symbol: &crate::plugins::navigation::IndexedSymbol,
|
||||
@@ -1038,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(
|
||||
@@ -1133,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
|
||||
}
|
||||
|
||||
@@ -1195,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
|
||||
});
|
||||
@@ -1341,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]
|
||||
@@ -1363,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 {
|
||||
@@ -1379,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)]
|
||||
@@ -1607,6 +1928,76 @@ mod tests {
|
||||
assert!(string_at(&structure, &snap, Pos::new(1, 53)), "inside \"users\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_names_on_code_identifiers_are_told_apart_from_strings_and_comments() {
|
||||
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
|
||||
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let anchor = |text: &str, line: u32, name: &str| {
|
||||
let col = text.lines().nth(line as usize).unwrap().find(name).unwrap() as u32;
|
||||
Range::new(Pos::new(line, col), Pos::new(line, col + name.len() as u32))
|
||||
};
|
||||
for (language, text, name) in [
|
||||
("java", "class Order {}", "Order"),
|
||||
("kotlin", "class Order", "Order"),
|
||||
("typescript", "class Order {}", "Order"),
|
||||
("python", "class Order:\n pass", "Order"),
|
||||
("go", "package app\ntype Order struct {}", "Order"),
|
||||
("rust", "struct Order {}", "Order"),
|
||||
("cpp", "class Order {};", "Order"),
|
||||
("go", "package app\nfunc f() { retry: goto retry }", "retry"),
|
||||
("ruby", "@order = 1", "@order"),
|
||||
("ruby", "@@order = 1", "@@order"),
|
||||
("ruby", "$order = 1", "$order"),
|
||||
("php", "<?php $order = 1;", "$order"),
|
||||
] {
|
||||
let language = ide_core::LanguageId::new(language);
|
||||
let snap = ide_syntax::Snapshot::text(&language, text);
|
||||
let line = u32::from(text.starts_with("package"));
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
|
||||
}
|
||||
for (language, text, name) in [
|
||||
("java", "class App { String route = \"orders.index\"; }", "orders.index"),
|
||||
("kotlin", "val route = \"orders.index\"", "orders.index"),
|
||||
("typescript", "const route = \"orders.index\";", "orders.index"),
|
||||
("python", "route = \"orders.index\"", "orders.index"),
|
||||
("go", "package app\nvar route = \"orders.index\"", "orders.index"),
|
||||
("rust", "const ROUTE: &str = \"orders.index\";", "orders.index"),
|
||||
("cpp", "const char* route = \"orders.index\";", "orders.index"),
|
||||
("json", "{\"orders.index\": \"orders.index\"}", "orders.index"),
|
||||
("yaml", "orders.index: orders.index", "orders.index"),
|
||||
("xml", "<select id=\"orders.index\" />", "id"),
|
||||
] {
|
||||
let language = ide_core::LanguageId::new(language);
|
||||
let snap = ide_syntax::Snapshot::text(&language, text);
|
||||
let line = u32::from(text.starts_with("package"));
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
|
||||
}
|
||||
let php = ide_core::LanguageId::new("php");
|
||||
let text = "<?php\nuse App\\Models\\Order;\nclass Order {}\n// Order is a model\n\
|
||||
return route('users.show', Order::query());\n";
|
||||
let snap = ide_syntax::Snapshot::text(&php, text);
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 1, "Order"), &cancel), "an imported class");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a class declaration");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 4, "Order"), &cancel), "a static call's class");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 4, "users.show"), &cancel), "a route name");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 3, "Order"), &cancel), "a comment");
|
||||
let ruby = ide_core::LanguageId::new("ruby");
|
||||
let text = "class Order\nend\nOrder.where(status: \"paid\")\n";
|
||||
let snap = ide_syntax::Snapshot::text(&ruby, text);
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 0, "Order"), &cancel), "a Ruby class name");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a Ruby constant reference");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 2, "paid"), &cancel), "a Ruby string");
|
||||
let xml = ide_core::LanguageId::new("xml");
|
||||
let text = "<mapper>\n <select id=\"selectById\">select 1</select>\n</mapper>\n";
|
||||
let snap = ide_syntax::Snapshot::text(&xml, text);
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 1, "selectById"), &cancel), "a MyBatis statement id");
|
||||
let idx = ide_core::LanguageId::new("idx");
|
||||
let text = "def route\n";
|
||||
let snap = ide_syntax::Snapshot::text(&idx, text);
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 0, "route"), &cancel), "a language without a grammar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ide_helpers_give_way_to_real_declarations() {
|
||||
let at = |path: &str| Location { path: path.into(), range: Range::default(), uri: None };
|
||||
@@ -1634,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,534 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
/// 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 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);
|
||||
}
|
||||
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"));
|
||||
}
|
||||
@@ -452,6 +453,7 @@ impl PluginHost {
|
||||
return vec![];
|
||||
};
|
||||
let mut result = vec![];
|
||||
let mut icons = HashMap::new();
|
||||
for path in paths {
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
@@ -462,6 +464,7 @@ impl PluginHost {
|
||||
if version_of(path).is_some_and(|v| file.version != Some(v)) {
|
||||
continue;
|
||||
}
|
||||
let prefixes = icons.entry(id).or_insert_with(|| marker_icons(plugin));
|
||||
let mut markers = vec![];
|
||||
for anchor in &file.edges.markers {
|
||||
let candidates = graph.targets(&anchor.key);
|
||||
@@ -481,7 +484,15 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
if !targets.is_empty() {
|
||||
markers.push(NavigationResult { range: anchor.range, targets });
|
||||
markers.push(IndexedMarker {
|
||||
range: anchor.range,
|
||||
targets,
|
||||
plugin_id: id.clone(),
|
||||
icon: prefixes
|
||||
.iter()
|
||||
.find(|(prefix, _)| anchor.key.starts_with(prefix.as_str()))
|
||||
.map(|(_, icon)| icon.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !markers.is_empty() {
|
||||
@@ -516,6 +527,23 @@ pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
|
||||
struct Decl {
|
||||
extensions: Vec<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct MarkerIconPrefixes {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
markers: Vec<String>,
|
||||
}
|
||||
fn marker_icons(p: &super::LoadedPlugin) -> Vec<(String, String)> {
|
||||
let mut prefixes: Vec<_> = p
|
||||
.manifest()
|
||||
.declarations(super::ui_points::MARKER_ICONS)
|
||||
.into_iter()
|
||||
.filter_map(|d| decode::<MarkerIconPrefixes>(super::ui_points::MARKER_ICONS, d).ok())
|
||||
.flat_map(|d| d.markers.into_iter().map(move |prefix| (prefix, d.id.clone())))
|
||||
.collect();
|
||||
prefixes.sort_by_key(|(prefix, _)| std::cmp::Reverse(prefix.len()));
|
||||
prefixes
|
||||
}
|
||||
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
|
||||
p.manifest()
|
||||
.declarations("documentIndexers")
|
||||
|
||||
@@ -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,59 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
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 +530,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 +550,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 +606,22 @@ 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(())
|
||||
});
|
||||
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 +743,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 +802,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)?;
|
||||
}
|
||||
@@ -549,6 +562,10 @@ struct ViewDecl {
|
||||
icon: Option<String>,
|
||||
#[serde(default = "right")]
|
||||
location: String,
|
||||
/// Place the tool window in the secondary split of its dock (`left` → Left Bottom,
|
||||
/// `right` → Right Bottom, `bottom` → Bottom Right).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
secondary: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
group: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -598,6 +615,12 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id));
|
||||
}
|
||||
}
|
||||
if d.secondary.is_some() {
|
||||
pkg.manifest.needs_api("contributes.views.secondary", 3)?;
|
||||
if d.location == SERVICES_LOCATION {
|
||||
return Err(format!("view {:?}: `secondary` does not apply to a services view", d.id));
|
||||
}
|
||||
}
|
||||
if d.detail {
|
||||
pkg.manifest.needs_api(&format!("view {:?}: `detail`", d.id), 3)?;
|
||||
if d.web.is_some() {
|
||||
@@ -734,6 +757,12 @@ struct MarkerIconDecl {
|
||||
icon: PathBuf,
|
||||
#[serde(default)]
|
||||
icon_dark: Option<PathBuf>,
|
||||
/// Key prefixes of document-indexer markers drawn with this icon.
|
||||
#[serde(default)]
|
||||
markers: Vec<String>,
|
||||
/// Shown above the targets of those markers.
|
||||
#[serde(default)]
|
||||
tooltip: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
@@ -743,7 +772,25 @@ pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, St
|
||||
if d.icon.to_string_lossy().len() > 256 {
|
||||
return Err("marker icon paths are up to 256 bytes".into());
|
||||
}
|
||||
let mut out = UiDecl::new(d.id.clone(), d.id.clone(), serde_json::json!({ "id": d.id, "icon": d.icon }));
|
||||
if d.markers.len() > MAX_PATTERNS || d.markers.iter().any(|m| m.is_empty() || m.len() > 256) {
|
||||
return Err(format!(
|
||||
"marker icon {:?}: `markers` lists up to {MAX_PATTERNS} key prefixes of 1–256 bytes",
|
||||
d.id
|
||||
));
|
||||
}
|
||||
if let Some(tooltip) = &d.tooltip {
|
||||
if d.markers.is_empty() {
|
||||
return Err(format!("marker icon {:?}: `tooltip` applies to indexed markers; set `markers`", d.id));
|
||||
}
|
||||
if tooltip.trim().is_empty() || tooltip.len() > MAX_TITLE {
|
||||
return Err(format!("marker icon {:?}: tooltips are 1–{MAX_TITLE} bytes", d.id));
|
||||
}
|
||||
}
|
||||
let mut out = UiDecl::new(
|
||||
d.id.clone(),
|
||||
d.id.clone(),
|
||||
serde_json::json!({ "id": d.id, "icon": d.icon, "markers": d.markers, "tooltip": d.tooltip }),
|
||||
);
|
||||
for (path, variant) in [(Some(&d.icon), "light"), (d.icon_dark.as_ref(), "dark")] {
|
||||
let Some(path) = path else { continue };
|
||||
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
|
||||
@@ -931,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,
|
||||
|
||||
@@ -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();
|
||||
@@ -279,6 +282,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "workspace_create"
|
||||
| "workspace_discover_projects"
|
||||
| "workspace_update"
|
||||
| "workspace_attach"
|
||||
| "cancel_request"
|
||||
@@ -395,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
|
||||
}
|
||||
@@ -16,15 +16,22 @@ struct Fixture {
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
Self::with_manifest(|_| {})
|
||||
}
|
||||
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));
|
||||
host.install_from_dir(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"),
|
||||
&Consent::all(),
|
||||
)
|
||||
.unwrap();
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
|
||||
let package = tempfile::tempdir().unwrap();
|
||||
let pkg = package.path();
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
for file in ["plugin.toml", "dist/plugin.wasm"] {
|
||||
std::fs::copy(fixture.join(file), pkg.join(file)).unwrap();
|
||||
}
|
||||
edit(pkg);
|
||||
host.install_from_dir(pkg, &Consent::all()).unwrap();
|
||||
Self { _tmp: tmp, root, host }
|
||||
}
|
||||
fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option<u32>, scope: &str) -> PathBuf {
|
||||
@@ -140,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();
|
||||
@@ -187,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,
|
||||
@@ -237,6 +287,47 @@ fn markers_and_disable_follow_graph_state() {
|
||||
assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_icons_map_indexed_markers_by_longest_key_prefix() {
|
||||
let f = Fixture::with_manifest(|pkg| {
|
||||
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap().replace("^1.2", "^1.3");
|
||||
std::fs::create_dir_all(pkg.join("icons")).unwrap();
|
||||
std::fs::write(
|
||||
pkg.join("icons/marker.svg"),
|
||||
r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"{manifest}
|
||||
[[contributes.markerIcons]]
|
||||
id = "mapping"
|
||||
icon = "icons/marker.svg"
|
||||
markers = ["mapping:"]
|
||||
|
||||
[[contributes.markerIcons]]
|
||||
id = "mapping-item"
|
||||
icon = "icons/marker.svg"
|
||||
markers = ["other:", "mapping:item"]
|
||||
tooltip = "Mapped item"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let source = f.index("source.idx", "def source:item\nref mapping:item\nref mapping:other\n", &[], Some(1), "app");
|
||||
let mapping = f.index("mapping.idx", "def mapping:item\ndef mapping:other\nref source:item\n", &[], Some(1), "app");
|
||||
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
|
||||
let icons = |path: &PathBuf| {
|
||||
let entry = entries.iter().find(|e| &e.path == path).unwrap();
|
||||
assert!(entry.markers.iter().all(|m| m.plugin_id == "test.indexer"));
|
||||
entry.markers.iter().map(|m| m.icon.as_deref()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(icons(&source), [Some("mapping-item"), Some("mapping")]);
|
||||
assert_eq!(icons(&mapping), [None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ranges_and_oversized_documents_never_publish() {
|
||||
let f = Fixture::new();
|
||||
|
||||
@@ -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));
|
||||
@@ -213,6 +211,7 @@ async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
|
||||
.unwrap();
|
||||
}
|
||||
native_rename_refusal(&host, &project, doc.id, &first, source).await;
|
||||
code_identifier_rename(&host, &plugins, &root, &fixture).await;
|
||||
plugins.set_enabled("test.indexer", false).unwrap();
|
||||
plugins.set_enabled("test.duplicate", false).unwrap();
|
||||
assert!(plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 4), Some("main")).is_none());
|
||||
@@ -392,3 +391,171 @@ async fn native_rename_refusal(
|
||||
peer.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async fn code_identifier_rename(host: &Host, plugins: &PluginHost, root: &Path, fixture: &Path) {
|
||||
use ide_core::{FileEdit, Range};
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
let plugin = root.join("ruby-indexer");
|
||||
std::fs::create_dir_all(plugin.join("dist")).unwrap();
|
||||
std::fs::copy(fixture.join("dist/plugin.wasm"), plugin.join("dist/plugin.wasm")).unwrap();
|
||||
std::fs::write(
|
||||
plugin.join("plugin.toml"),
|
||||
std::fs::read_to_string(fixture.join("plugin.toml"))
|
||||
.unwrap()
|
||||
.replace("test.indexer", "test.ruby")
|
||||
.replace(r#"extensions = ["idx"]"#, r#"extensions = ["rb"]"#),
|
||||
)
|
||||
.unwrap();
|
||||
plugins.install_from_dir(&plugin, &Consent::all()).unwrap();
|
||||
let app = root.join("app");
|
||||
std::fs::create_dir_all(&app).unwrap();
|
||||
let source = "def order\nend\nref order\nref \"orders.index\"\n";
|
||||
let used = "ref order\nref \"orders.index\"\n";
|
||||
let model = app.join("order.rb");
|
||||
let usage = app.join("orders_controller.rb");
|
||||
std::fs::write(&model, source).unwrap();
|
||||
std::fs::write(&usage, used).unwrap();
|
||||
let project = host.open_light("ruby", &model).unwrap();
|
||||
let doc = project.documents.open(dunce::simplified(&model)).unwrap();
|
||||
for (path, text, version) in [(&model, source, Some(doc.version)), (&usage, used, None)] {
|
||||
plugins
|
||||
.index_navigation(
|
||||
&project.root,
|
||||
Snapshot { path, text, version, symbols: &[], scope: "app" },
|
||||
Invocation {
|
||||
client: Some("ruby".into()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let edits = |files: &[FileEdit]| {
|
||||
let mut out: Vec<(String, u32, u32, String)> = files
|
||||
.iter()
|
||||
.flat_map(|file| match file {
|
||||
FileEdit::Edit { path, edits, .. } => {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
edits
|
||||
.iter()
|
||||
.map(|e| (name.clone(), e.range.start.line, e.range.start.col, e.new_text.clone()))
|
||||
.collect()
|
||||
}
|
||||
_ => vec![],
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
};
|
||||
let refusal = "The Ruby language server can't rename this symbol; \
|
||||
renaming only the plugin's indexed names would leave it half renamed";
|
||||
let ruby = ide_core::LanguageId::new("ruby");
|
||||
project.lsp.set_servers_hook(Box::new(|_, _| vec![]));
|
||||
project.lsp.did_open(doc.id, &model, &ruby, 0, source);
|
||||
for pos in [Pos::new(0, 5), Pos::new(2, 5)] {
|
||||
let refused = ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, pos).await.unwrap_err();
|
||||
assert_eq!((refused.kind, refused.message.as_str()), (ErrorKind::InvalidInput, refusal));
|
||||
let refused =
|
||||
ops::lsp::lsp_rename(host, "ruby", RequestId(120), doc.id, pos, "purchase".into()).await.unwrap_err();
|
||||
assert_eq!((refused.kind, refused.message.as_str()), (ErrorKind::InvalidInput, refusal));
|
||||
}
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, Pos::new(3, 6)).await.unwrap(),
|
||||
Some(Range::new(Pos::new(3, 4), Pos::new(3, 18))),
|
||||
"a name inside a string stays the plugin's"
|
||||
);
|
||||
let list = "\"orders.list\"".to_owned();
|
||||
let renamed =
|
||||
ops::lsp::lsp_rename(host, "ruby", RequestId(121), doc.id, Pos::new(3, 6), list.clone()).await.unwrap();
|
||||
assert!(renamed.preview);
|
||||
assert_eq!(
|
||||
edits(&renamed.edit.files),
|
||||
[("order.rb".to_owned(), 3, 4, list.clone()), ("orders_controller.rb".to_owned(), 1, 4, list)]
|
||||
);
|
||||
project.lsp.did_close(doc.id);
|
||||
struct Native(Arc<AtomicU8>);
|
||||
impl Handler for Native {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
let mode = self.0.load(Ordering::SeqCst);
|
||||
let name = json!({"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 9}});
|
||||
Ok(match method {
|
||||
"initialize" => {
|
||||
json!({"capabilities": {"textDocumentSync": 1, "renameProvider": {"prepareProvider": true}}})
|
||||
}
|
||||
"textDocument/prepareRename" if mode == 0 => name,
|
||||
"textDocument/prepareRename" if mode == 2 => {
|
||||
json!({"start": {"line": 2, "character": 0}, "end": {"line": 2, "character": 3}})
|
||||
}
|
||||
"textDocument/prepareRename" if mode == 3 => return Err((-32603, "cannot check name".into())),
|
||||
"textDocument/rename" if mode == 0 => {
|
||||
let uri = params["textDocument"]["uri"].as_str().unwrap().to_owned();
|
||||
json!({"changes": {uri: [{"range": name, "newText": params["newName"]}]}})
|
||||
}
|
||||
_ => Value::Null,
|
||||
})
|
||||
}
|
||||
}
|
||||
let mode = Arc::new(AtomicU8::new(0));
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
|
||||
let accepted = peers.clone();
|
||||
let state = mode.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
let (reader, writer) = stream.into_split();
|
||||
accepted.lock().push(Peer::connect(reader, writer, Arc::new(Native(state.clone()))));
|
||||
}
|
||||
});
|
||||
let config: ide_language::LanguageServerConfig = toml::from_str(&format!(
|
||||
"name = 'ruby-renamer'\ncommand = 'no-process-required'\nconnect = '127.0.0.1:{port}'"
|
||||
))
|
||||
.unwrap();
|
||||
project.lsp.set_servers_hook(Box::new(move |_, _| vec![config.clone()]));
|
||||
project.lsp.did_open(doc.id, &model, &ruby, 0, source);
|
||||
let everywhere: Vec<_> = [("order.rb", 0), ("order.rb", 2), ("orders_controller.rb", 0)]
|
||||
.map(|(file, line)| (file.to_owned(), line, 4, "purchase".to_owned()))
|
||||
.into();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, Pos::new(2, 5)).await.unwrap(),
|
||||
Some(Range::new(Pos::new(2, 4), Pos::new(2, 9)))
|
||||
);
|
||||
let renamed =
|
||||
ops::lsp::lsp_rename(host, "ruby", RequestId(122), doc.id, Pos::new(2, 5), "purchase".into()).await.unwrap();
|
||||
assert!(renamed.preview);
|
||||
assert_eq!(edits(&renamed.edit.files), everywhere, "the server's rename and the indexed names merge");
|
||||
for value in 1..=3 {
|
||||
mode.store(value, Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, Pos::new(2, 5)).await.unwrap(),
|
||||
Some(Range::new(Pos::new(2, 4), Pos::new(2, 9)))
|
||||
);
|
||||
let renamed = ops::lsp::lsp_rename(host, "ruby", RequestId(123), doc.id, Pos::new(2, 5), "purchase".into())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(edits(&renamed.edit.files), everywhere, "declined or failed preparation retains indexed edits");
|
||||
assert_eq!(renamed.incomplete.is_some(), value == 3, "only protocol errors warn about incomplete results");
|
||||
}
|
||||
host.cancel_request(RequestId(124));
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_rename(host, "ruby", RequestId(124), doc.id, Pos::new(2, 5), "purchase".into())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::Cancelled
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&model).unwrap(), source);
|
||||
assert_eq!(std::fs::read_to_string(&usage).unwrap(), used);
|
||||
project.lsp.did_close(doc.id);
|
||||
project.lsp.shutdown_all().await;
|
||||
project.lsp.set_servers_hook(Box::new(|_, _| vec![]));
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
for peer in peers.lock().drain(..) {
|
||||
peer.kill();
|
||||
}
|
||||
host.close_project("ruby");
|
||||
}
|
||||
@@ -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") {
|
||||
return Err(HostError::NotFound(id.into()));
|
||||
}
|
||||
Ok(ide_run::RunConfig {
|
||||
id: format!("plugin:execution:{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,
|
||||
@@ -5314,6 +5335,55 @@ iconDark = "{dark}"
|
||||
assert!(f.host.assets("marker-icons", "markerIcon/resource/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_icons_validate_indexed_marker_prefixes_and_tooltips() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(pkg.join("icons")).unwrap();
|
||||
std::fs::write(pkg.join("icons/usages.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
|
||||
.unwrap();
|
||||
let manifest = |engine: &str, fields: &str| {
|
||||
format!(
|
||||
r#"
|
||||
id = "indexed-marker-icons"
|
||||
name = "Indexed Marker Icons"
|
||||
version = "1.0.0"
|
||||
engines.openidea = "{engine}"
|
||||
[[contributes.markerIcons]]
|
||||
id = "usages"
|
||||
icon = "icons/usages.svg"
|
||||
{fields}
|
||||
"#
|
||||
)
|
||||
};
|
||||
let long = "k".repeat(257);
|
||||
let many = format!("[{}]", (0..17).map(|i| format!("\"k{i}#\"")).collect::<Vec<_>>().join(", "));
|
||||
for (engine, fields) in [
|
||||
("^1.0", r#"markers = ["usages#"]"#.to_owned()),
|
||||
("^1.3", r#"markers = [""]"#.to_owned()),
|
||||
("^1.3", format!(r#"markers = ["{long}"]"#)),
|
||||
("^1.3", format!("markers = {many}")),
|
||||
("^1.3", r#"markers = "usages#""#.to_owned()),
|
||||
("^1.3", r#"tooltip = "Usages""#.to_owned()),
|
||||
("^1.3", "markers = [\"usages#\"]\ntooltip = \" \"".to_owned()),
|
||||
("^1.3", format!("markers = [\"usages#\"]\ntooltip = \"{long}\"")),
|
||||
] {
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest(engine, &fields)).unwrap();
|
||||
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "accepted {fields}");
|
||||
}
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
manifest("^1.3", "markers = [\"usages#\", \"scope#\"]\ntooltip = \"Usages\""),
|
||||
)
|
||||
.unwrap();
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let decl = &f.host.ui_items("markerIcons")[0].decl;
|
||||
assert_eq!(decl["markers"], serde_json::json!(["usages#", "scope#"]));
|
||||
assert_eq!(decl["tooltip"], "Usages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_providers_preserve_names_and_package_paths() {
|
||||
let f = fixture(Limits::default());
|
||||
@@ -5544,4 +5614,414 @@ fn view_header_none_needs_api_1_3_and_an_ungrouped_web_view() {
|
||||
let info = f.install(&pkg);
|
||||
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
|
||||
assert_eq!(f.host.ui_items("views")[0].decl["header"], "none");
|
||||
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest("^1.2", "web = \"web/index.html\"\nsecondary = true")).unwrap();
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err().message;
|
||||
assert!(err.contains("new in plugin API 1.3"), "{err}");
|
||||
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
"id = \"headerless\"\nname = \"Headerless\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[[contributes.views]]\nid = \"panel\"\ntitle = \"Panel\"\nlocation = \"right\"\nsecondary = true\nweb = \"web/index.html\"\nheader = \"none\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let info = f.install(&pkg);
|
||||
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
|
||||
assert_eq!(f.host.ui_items("views")[0].decl["secondary"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_secondary_requires_api_1_3_and_a_boolean_tool_window_placement() {
|
||||
let f = fixture(Limits::default());
|
||||
for location in ["left", "right", "bottom"] {
|
||||
for fields in ["", "secondary = false", "secondary = true", "group = \"g\"\nsecondary = true"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(tmp.path(), |t| {
|
||||
t.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
|
||||
.replace("location = \"right\"", &format!("location = {location:?}\n{fields}"))
|
||||
});
|
||||
assert!(f.install(&pkg).error.is_none());
|
||||
let decl = &f.host.ui_items("views")[0].decl;
|
||||
assert_eq!(decl["location"], location);
|
||||
let expected = if fields.is_empty() { None } else { Some(!fields.contains("false")) };
|
||||
assert_eq!(decl.get("secondary").and_then(|v| v.as_bool()), expected);
|
||||
}
|
||||
}
|
||||
for (api, location, value, message) in [
|
||||
("^1.0", "right", "true", "new in plugin API 1.3"),
|
||||
("^1.2", "right", "false", "new in plugin API 1.3"),
|
||||
("^1.3", "services", "true", "does not apply to a services view"),
|
||||
("^1.3", "services", "false", "does not apply to a services view"),
|
||||
("^1.3", "bottom", "\"yes\"", "boolean"),
|
||||
("^1.3", "left", "1", "boolean"),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(tmp.path(), |t| {
|
||||
t.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = {api:?}"))
|
||||
.replace("location = \"right\"", &format!("location = {location:?}\nsecondary = {value}"))
|
||||
});
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
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());
|
||||
}
|
||||
|
||||
#[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,444 @@ 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);
|
||||
tokio::spawn(rpc::serve(host, 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("[]"));
|
||||
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"));
|
||||
plugins.host().shutdown();
|
||||
desktop.shutdown();
|
||||
peer.kill();
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
@@ -32,6 +32,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
assert!(!host.open_project("main", &root).unwrap().is_trusted());
|
||||
for command in [
|
||||
"app_info",
|
||||
"workspace_discover_projects",
|
||||
"app_build_info",
|
||||
"app_cli_install",
|
||||
"app_cli_uninstall",
|
||||
|
||||
@@ -165,3 +165,44 @@ async fn php_language_level_follows_the_member_of_the_file() {
|
||||
assert_eq!(level_elsewhere.member, None);
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn build_tool_availability_follows_workspace_members_and_trust() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let members: Vec<_> = ["first", "second"].into_iter().map(|name| base.join(name)).collect();
|
||||
for member in &members {
|
||||
std::fs::create_dir(member).unwrap();
|
||||
std::fs::write(member.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
|
||||
std::fs::write(member.join("build.gradle"), "").unwrap();
|
||||
}
|
||||
let root = base.join("workspace");
|
||||
Workspace::save(&root, "Build tools", members.clone(), true).unwrap();
|
||||
let host = 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(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
for member in &members {
|
||||
host.trust.decide(member, true, false).unwrap();
|
||||
}
|
||||
host.open_project("main", &root).unwrap();
|
||||
assert!(ops::build::build_model(&host, "main").await.is_err());
|
||||
host.close_project("main");
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let model = ops::build::build_model(&host, "main").await.unwrap();
|
||||
assert_eq!(model.maven.unwrap().projects.len(), 2);
|
||||
assert_eq!(model.gradle.len(), 2);
|
||||
ops::project::save_workspace(&host, "main", "One member", vec![members[1].clone()]).unwrap();
|
||||
let model = ops::build::build_model(&host, "main").await.unwrap();
|
||||
assert_eq!(model.maven.unwrap().projects[0].dir, ide_core::ipc_path::to_ipc(&members[1]));
|
||||
assert_eq!(model.gradle.len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -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")));
|
||||
|
||||
@@ -51,6 +51,7 @@ impl ClientHandler {
|
||||
let message = value.get("message").and_then(Value::as_str).map(str::to_owned);
|
||||
let fraction = value.get("percentage").and_then(Value::as_f64).map(|p| (p / 100.0).clamp(0.0, 1.0) as f32);
|
||||
let cancellable = value.get("cancellable").and_then(Value::as_bool);
|
||||
let mut replaced = None;
|
||||
let event = {
|
||||
let mut progress = self.server.progress.lock();
|
||||
match value.get("kind").and_then(Value::as_str) {
|
||||
@@ -64,7 +65,19 @@ impl ClientHandler {
|
||||
let task = TaskId::next();
|
||||
let cancel = (cancellable == Some(true)).then(|| self.cancel_handle(task, ¶ms["token"]));
|
||||
let cancellable = cancel.is_some();
|
||||
progress.insert(token, Progress { task, title: title.clone(), message: message.clone(), cancel });
|
||||
// A token carries one `begin`, yet servers reuse one for concurrent work
|
||||
// (PHPantom: begin, begin, end, end). The task it replaces ends now, or no
|
||||
// `end` would ever reach it.
|
||||
replaced = progress
|
||||
.insert(token, Progress { task, title: title.clone(), message: message.clone(), cancel })
|
||||
.map(|p| IdeEvent::Task {
|
||||
id: p.task,
|
||||
title: p.title,
|
||||
message: p.message,
|
||||
fraction: None,
|
||||
done: true,
|
||||
cancellable: false,
|
||||
});
|
||||
IdeEvent::Task { id: task, title, message, fraction, done: false, cancellable }
|
||||
}
|
||||
Some("report") => {
|
||||
@@ -102,7 +115,10 @@ impl ClientHandler {
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
let ended = matches!(&event, IdeEvent::Task { done: true, .. });
|
||||
let ended = replaced.is_some() || matches!(&event, IdeEvent::Task { done: true, .. });
|
||||
if let Some(replaced) = replaced {
|
||||
self.server.emit(mgr.sink(), replaced);
|
||||
}
|
||||
self.server.emit(mgr.sink(), event);
|
||||
self.server.refresh_busy_status(mgr.sink());
|
||||
if ended {
|
||||
|
||||
@@ -788,10 +788,11 @@ impl LspManager {
|
||||
|
||||
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
|
||||
/// class files are up to date before a launch.
|
||||
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
|
||||
/// `full` asks jdtls for a clean build (`java/buildWorkspace(true)`, IDEA's Rebuild).
|
||||
pub async fn java_build(&self, path: &Path, full: bool, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
|
||||
let (server, _) = self.jdtls(path, cancel).await?;
|
||||
let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?;
|
||||
let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(full), BUILD_TIMEOUT, cancel).await?;
|
||||
// The first build can finish the Maven import before ServiceReady is handled.
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
let changed = cancellable_wait(cancel, self.java_configure_compiler(&server, &run)).await??;
|
||||
@@ -799,7 +800,7 @@ impl LspManager {
|
||||
let recheck = v.as_i64() == Some(2)
|
||||
&& compiler_defaults(&server.effective_config()).as_object().is_some_and(|o| !o.is_empty());
|
||||
if changed || recheck {
|
||||
v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
v = self.jdtls_request(path, "java/buildWorkspace", json!(full), BUILD_TIMEOUT, cancel).await?;
|
||||
}
|
||||
Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0)))
|
||||
}
|
||||
@@ -842,10 +843,11 @@ impl LspManager {
|
||||
module: &Path,
|
||||
test: bool,
|
||||
build: bool,
|
||||
full_build: bool,
|
||||
build_lock_held: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let build = if build { Some(self.java_build(module, cancel).await?) } else { None };
|
||||
let build = if build { Some(self.java_build(module, full_build, cancel).await?) } else { None };
|
||||
if build == Some(JavaBuildStatus::Cancelled) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
@@ -875,8 +877,8 @@ impl LspManager {
|
||||
};
|
||||
(cp, java_home)
|
||||
};
|
||||
let (errors, error_count, scoped) = match build {
|
||||
Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => {
|
||||
let (errors, error_count, scoped) = match (build, build_lock_held) {
|
||||
(Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed), _) | (None, true) => {
|
||||
let scope_module = module.to_path_buf();
|
||||
let cp = cp.clone();
|
||||
let scope = tokio::task::spawn_blocking(move || LaunchScope::new(&scope_module, test, cp.as_ref()))
|
||||
@@ -888,7 +890,9 @@ impl LspManager {
|
||||
}
|
||||
_ => (Vec::new(), 0, false),
|
||||
};
|
||||
let build = if build == Some(JavaBuildStatus::WithErrors) && error_count == 0 && scoped {
|
||||
let build = if build.is_none() && build_lock_held && error_count > 0 {
|
||||
Some(JavaBuildStatus::WithErrors)
|
||||
} else if build == Some(JavaBuildStatus::WithErrors) && error_count == 0 && scoped {
|
||||
Some(JavaBuildStatus::Succeeded)
|
||||
} else {
|
||||
build
|
||||
|
||||
@@ -1306,6 +1306,111 @@ async fn progress_titled_with_an_executable_path_shows_the_program_name() {
|
||||
assert_eq!(titles, ["cargo check"; 3]);
|
||||
}
|
||||
|
||||
/// PHPantom reuses a fixed token for concurrent requests: begin, begin, end, end.
|
||||
/// Every task it started ends, and the server reads ready afterwards.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_progress_token_begun_again_ends_the_task_it_replaces() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let peer = h.launcher.state("mock").peer();
|
||||
let progress =
|
||||
|token: &str, kind: &str, title: &str| json!({ "token": token, "value": { "kind": kind, "title": title } });
|
||||
for kind in ["begin", "begin", "end", "end"] {
|
||||
peer.notify("$/progress", progress("find_references", kind, "Finding references")).unwrap();
|
||||
}
|
||||
// Notifications are handled in order: once this task ended, so did everything above.
|
||||
peer.notify("$/progress", progress("later", "begin", "Indexing")).unwrap();
|
||||
peer.notify("$/progress", progress("later", "end", "Indexing")).unwrap();
|
||||
eventually(|| {
|
||||
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "Indexing"))
|
||||
})
|
||||
.await;
|
||||
let (mut begun, mut ended) = (Vec::new(), Vec::new());
|
||||
for e in h.events.lock().iter() {
|
||||
if let IdeEvent::Task { id, title, done, .. } = e
|
||||
&& title == "Finding references"
|
||||
{
|
||||
if *done { ended.push(*id) } else { begun.push(*id) }
|
||||
}
|
||||
}
|
||||
assert_eq!(begun.len(), 2);
|
||||
for id in &begun {
|
||||
assert_eq!(ended.iter().filter(|e| *e == id).count(), 1, "every task ends once: {begun:?} {ended:?}");
|
||||
}
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn replacing_progress_keeps_cancellation_reports_and_indexing_on_the_new_task() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "target");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let peer = s.peer();
|
||||
let begin = |title| json!({ "token": 7, "value": { "kind": "begin", "title": title, "cancellable": true } });
|
||||
let task_of = |title: &str| {
|
||||
h.events.lock().iter().find_map(|e| match e {
|
||||
IdeEvent::Task { id, title: t, done: false, .. } if t == title => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
peer.notify("$/progress", begin("Old work")).unwrap();
|
||||
h.wait_status("mock", ServerStatus::Busy).await;
|
||||
let old = task_of("Old work").unwrap();
|
||||
let mgr = h.mgr.clone();
|
||||
let search = tokio::spawn(async move { mgr.references(doc, Pos::new(0, 1), true, true, Default::default()).await });
|
||||
eventually(|| !s.requested("textDocument/references").is_empty()).await;
|
||||
|
||||
peer.notify("$/progress", begin("New work")).unwrap();
|
||||
peer.notify(
|
||||
"$/progress",
|
||||
json!({ "token": 7, "value": { "kind": "report", "message": "Halfway", "percentage": 50 } }),
|
||||
)
|
||||
.unwrap();
|
||||
eventually(|| {
|
||||
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { title, message: Some(m), fraction: Some(f), done: false, .. } if title == "New work" && m == "Halfway" && *f == 0.5))
|
||||
})
|
||||
.await;
|
||||
let new = task_of("New work").unwrap();
|
||||
assert_ne!(old, new);
|
||||
assert!(!ide_core::task::cancel(old), "a replaced task cannot cancel its successor");
|
||||
assert!(ide_core::task::cancel(new));
|
||||
eventually(|| !s.notified("window/workDoneProgress/cancel").is_empty()).await;
|
||||
assert_eq!(s.notified("window/workDoneProgress/cancel"), [json!({ "token": 7 })]);
|
||||
assert_eq!(h.statuses("mock").last(), Some(&ServerStatus::Busy));
|
||||
assert!(!search.is_finished(), "replacement must not release a search waiting for idle");
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 1), true, false, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
|
||||
|
||||
for _ in 0..2 {
|
||||
peer.notify("$/progress", json!({ "token": 7, "value": { "kind": "end" } })).unwrap();
|
||||
}
|
||||
// A later task is a barrier for both end notifications.
|
||||
peer.notify("$/progress", json!({ "token": "barrier", "value": { "kind": "begin", "title": "Barrier" } })).unwrap();
|
||||
peer.notify("$/progress", json!({ "token": "barrier", "value": { "kind": "end" } })).unwrap();
|
||||
eventually(|| {
|
||||
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "Barrier"))
|
||||
})
|
||||
.await;
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let refs = tokio::time::timeout(Duration::from_secs(5), search).await.unwrap().unwrap().unwrap();
|
||||
assert_eq!(refs.incomplete, None);
|
||||
assert!(!ide_core::task::cancel(new));
|
||||
let events = h.events.lock();
|
||||
for id in [old, new] {
|
||||
assert_eq!(
|
||||
events.iter().filter(|e| matches!(e, IdeEvent::Task { id: t, done: true, .. } if *t == id)).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, IdeEvent::Task { id, message: Some(m), .. } if *id == old && m == "Halfway"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancelling_a_cancellable_progress_task_asks_the_server_to_stop() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -1752,7 +1857,7 @@ async fn java_source_paths_reach_initialization_live_configuration_and_builds()
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
|
||||
state.respond("java/buildWorkspace", json!(1));
|
||||
assert_eq!(
|
||||
h.mgr.java_build(&h.root, &Default::default()).await.unwrap(),
|
||||
h.mgr.java_build(&h.root, false, &Default::default()).await.unwrap(),
|
||||
ide_lsp::jdtls::JavaBuildStatus::Succeeded
|
||||
);
|
||||
assert_eq!(state.requested("java/buildWorkspace"), vec![json!(false)]);
|
||||
@@ -1768,6 +1873,18 @@ async fn java_source_paths_reach_initialization_live_configuration_and_builds()
|
||||
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_build_asks_for_a_full_build_when_rebuilding() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let state = h.launcher.state("jdtls");
|
||||
state.respond("java/buildWorkspace", json!(1));
|
||||
assert_eq!(
|
||||
h.mgr.java_build(&h.root, true, &Default::default()).await.unwrap(),
|
||||
ide_lsp::jdtls::JavaBuildStatus::Succeeded
|
||||
);
|
||||
assert_eq!(state.requested("java/buildWorkspace"), vec![json!(true)]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn unmanaged_java_run_imports_sources_without_an_open_editor() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
@@ -1780,7 +1897,7 @@ async fn unmanaged_java_run_imports_sources_without_an_open_editor() {
|
||||
let state = h.launcher.state("jdtls");
|
||||
state.respond("java/buildWorkspace", json!(1));
|
||||
assert_eq!(
|
||||
h.mgr.java_build(&h.root, &Default::default()).await.unwrap(),
|
||||
h.mgr.java_build(&h.root, false, &Default::default()).await.unwrap(),
|
||||
ide_lsp::jdtls::JavaBuildStatus::Succeeded
|
||||
);
|
||||
assert_eq!(state.requested("initialize")[0]["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
|
||||
@@ -1798,7 +1915,7 @@ async fn java_launch_serializes_the_classpath_with_the_build_lock() {
|
||||
// A launch with a build re-takes the lock after java_build has released it.
|
||||
let info = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
h.mgr.java_launch(&h.root, false, true, false, &Default::default()),
|
||||
h.mgr.java_launch(&h.root, false, true, false, false, &Default::default()),
|
||||
)
|
||||
.await
|
||||
.expect("java_launch deadlocked on the build lock")
|
||||
@@ -1808,10 +1925,13 @@ async fn java_launch_serializes_the_classpath_with_the_build_lock() {
|
||||
// The delegated external build holds the lock while java_launch resolves the
|
||||
// classpath; java_launch must not re-take it, or the launch would deadlock.
|
||||
let guard = h.mgr.java_external_build(&h.root, &Default::default()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(30), h.mgr.java_launch(&h.root, false, false, true, &Default::default()))
|
||||
.await
|
||||
.expect("java_launch deadlocked on the delegated external build's lock")
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
h.mgr.java_launch(&h.root, false, false, false, true, &Default::default()),
|
||||
)
|
||||
.await
|
||||
.expect("java_launch deadlocked on the delegated external build's lock")
|
||||
.unwrap();
|
||||
drop(guard);
|
||||
let commands: Vec<_> = state
|
||||
.requested("workspace/executeCommand")
|
||||
@@ -1858,7 +1978,7 @@ async fn java_launch_scopes_build_status_and_does_not_wait_for_unrelated_errors(
|
||||
eventually(|| diagnostics_for(&h, &file) == 1).await;
|
||||
let info = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
h.mgr.java_launch(&web, test, true, false, &Default::default()),
|
||||
h.mgr.java_launch(&web, test, true, false, false, &Default::default()),
|
||||
)
|
||||
.await
|
||||
.expect("an already-published error must not incur the diagnostics wait")
|
||||
@@ -1874,6 +1994,55 @@ async fn java_launch_scopes_build_status_and_does_not_wait_for_unrelated_errors(
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn delegated_java_launch_retains_scoped_errors_without_a_jdt_build() {
|
||||
use ide_lsp::jdtls::JavaBuildStatus;
|
||||
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("Main.java", "class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
let web = h.root.join("web");
|
||||
state.respond(
|
||||
"workspace/executeCommand",
|
||||
json!({"classpaths": [web.join("target/classes"), h.root.join("service/target/classes")]}),
|
||||
);
|
||||
for (path, test, errors) in [
|
||||
("web/src/main/java/Main.java", false, 1),
|
||||
("web/src/test/java/MainTest.java", false, 0),
|
||||
("web/src/test/java/MainTest.java", true, 1),
|
||||
("service/src/main/java/Service.java", false, 1),
|
||||
("service/src/test/java/ServiceTest.java", true, 0),
|
||||
("other/src/main/java/Other.java", false, 0),
|
||||
] {
|
||||
let file = h.root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(&file, "System.out.println(missingName);").unwrap();
|
||||
let diag = json!({"range": {"start":{"line":0,"character":19},"end":{"line":0,"character":30}},
|
||||
"severity": 1, "message": "missingName cannot be resolved to a variable"});
|
||||
state
|
||||
.peer()
|
||||
.notify("textDocument/publishDiagnostics", json!({"uri": uri_of(&file), "diagnostics": [diag]}))
|
||||
.unwrap();
|
||||
eventually(|| diagnostics_for(&h, &file) == 1).await;
|
||||
let guard = h.mgr.java_external_build(&web, &Default::default()).await.unwrap();
|
||||
let info = h.mgr.java_launch(&web, test, false, false, true, &Default::default()).await.unwrap();
|
||||
drop(guard);
|
||||
assert_eq!(info.error_count, errors, "{path}, test={test}");
|
||||
assert_eq!(info.build, (errors > 0).then_some(JavaBuildStatus::WithErrors));
|
||||
if errors > 0 {
|
||||
assert_eq!(info.errors[0].message, "missingName cannot be resolved to a variable");
|
||||
}
|
||||
state
|
||||
.peer()
|
||||
.notify("textDocument/publishDiagnostics", json!({"uri": uri_of(&file), "diagnostics": []}))
|
||||
.unwrap();
|
||||
eventually(|| diagnostics_for(&h, &file) == 2).await;
|
||||
h.events.lock().clear();
|
||||
}
|
||||
assert!(state.requested("java/buildWorkspace").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_java_files_are_checked_again_after_a_project_update() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
|
||||
@@ -156,7 +156,7 @@ async fn java_searches_skip_indexed_sources_but_keep_exclusions_outside_scope()
|
||||
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
|
||||
mgr.java_build(&path, &Default::default()).await.unwrap();
|
||||
mgr.java_build(&path, false, &Default::default()).await.unwrap();
|
||||
let uri = ide_lsp::path_to_uri_string;
|
||||
let covered = retry("imported source scope", Duration::from_secs(120), || async {
|
||||
mgr.jdtls_command(
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -35,7 +35,17 @@ pub struct Edges {
|
||||
pub struct FileMarkers {
|
||||
pub path: PathBuf,
|
||||
pub version: Option<u32>,
|
||||
pub markers: Vec<NavigationResult>,
|
||||
pub markers: Vec<IndexedMarker>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexedMarker {
|
||||
pub range: Range,
|
||||
pub targets: Vec<Location>,
|
||||
pub plugin_id: String,
|
||||
/// The `[[contributes.markerIcons]]` id whose `markers` prefix matches the marker's key.
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -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,55 @@ 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())
|
||||
}
|
||||
/// 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 +2185,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,6 +32,7 @@ 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"),
|
||||
];
|
||||
let official = repo().join("plugins/official");
|
||||
@@ -88,15 +89,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());
|
||||
|
||||
Loaded 100 of 616 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user